Spaces:
Running on Zero
Running on Zero
Download scripts/demo/view_3d.html from hugging-apps/urbex-spatial-grounding: direct link, hf CLI and curl.
- Browser
- Download file 22.7 kB
-
https://huggingface.co/spaces/hugging-apps/urbex-spatial-grounding/resolve/main/scripts/demo/view_3d.html
- Command line
-
hf download hf://spaces/hugging-apps/urbex-spatial-grounding/scripts/demo/view_3d.html
-
curl -L -o view_3d.html https://huggingface.co/spaces/hugging-apps/urbex-spatial-grounding/resolve/main/scripts/demo/view_3d.html
22.7 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>Urbex 3D view</title> | |
| <style> | |
| html, body { margin: 0; height: 100%; overflow: hidden; background: #090c11; } | |
| body { font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| color: #f4f6f9; -webkit-font-smoothing: antialiased; -webkit-user-select: none; user-select: none; } | |
| canvas { position: absolute; left: 0; top: 0; width: 100%; height: 100%; touch-action: none; cursor: grab; } | |
| canvas.dragging { cursor: grabbing; } | |
| .shade { position: absolute; left: 0; right: 0; pointer-events: none; } | |
| .shade.top { top: 0; height: 120px; background: linear-gradient(rgba(9, 12, 17, .74), rgba(9, 12, 17, 0)); } | |
| .shade.bottom { bottom: 0; height: 150px; background: linear-gradient(rgba(9, 12, 17, 0), rgba(9, 12, 17, .8)); } | |
| .hud { position: absolute; pointer-events: none; text-shadow: 0 1px 8px rgba(0, 0, 0, .9); } | |
| .hud.title { left: 24px; top: 18px; max-width: 45%; } | |
| .hud.title b { display: block; font-size: 26px; line-height: 1.15; letter-spacing: -.01em; } | |
| .hud.title span { display: block; margin-top: 3px; font-size: 12.5px; font-weight: 500; color: #d6dee8; } | |
| .hud.stages { right: 20px; top: 20px; text-align: right; } | |
| .pill { display: inline-block; margin-left: 6px; padding: 4px 11px; border-radius: 99px; font-size: 12px; | |
| font-weight: 600; color: #6c798a; background: rgba(30, 38, 50, .85); text-shadow: none; | |
| transition: background .25s, color .25s; } | |
| .pill.done { color: #e6eaef; } | |
| .pill.on { color: #fff; } | |
| .pill.on[data-stage="0"] { background: #7896be; } | |
| .pill.on[data-stage="1"] { background: #0096fa; } | |
| .pill.on[data-stage="2"] { background: #f88800; } | |
| #prediction { margin: 8px 0 0 auto; width: max-content; padding: 4px 12px; border-radius: 99px; font-size: 12.5px; | |
| font-weight: 600; color: #fff; background: #ff4b4b; text-shadow: none; opacity: 0; } | |
| .hud.query { left: 24px; bottom: 18px; max-width: min(66%, 860px); } | |
| .hud.query small { display: block; font-size: 10.5px; font-weight: 700; letter-spacing: .06em; color: #46b4ff; } | |
| .hud.query span { display: block; margin-top: 2px; font-size: 14.5px; } | |
| .hud.controls { right: 20px; bottom: 16px; text-align: right; pointer-events: auto; } | |
| button { margin-left: 6px; padding: 5px 13px; border: 1px solid rgba(255, 255, 255, .16); border-radius: 99px; | |
| font: inherit; font-size: 12px; font-weight: 600; color: #e6eaef; background: rgba(30, 38, 50, .85); | |
| cursor: pointer; } | |
| button:hover { background: rgba(52, 64, 82, .95); } | |
| button.on { border-color: transparent; background: #0096fa; color: #fff; } | |
| .hud.controls small { display: block; margin-top: 7px; font-size: 11.5px; color: #aab6c6; } | |
| #unavailable { position: absolute; left: 0; right: 0; top: 45%; text-align: center; color: #aab6c6; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="view"></canvas> | |
| <div class="shade top"></div><div class="shade bottom"></div> | |
| <div class="hud title"><b id="title"></b><span id="subtitle"></span></div> | |
| <div class="hud stages"> | |
| <div id="stages"> | |
| <span class="pill" data-stage="0">Global BEV</span><span class="pill" data-stage="1">Zoom in</span><span | |
| class="pill" data-stage="2">Oblique view</span> | |
| </div> | |
| <div id="prediction">Prediction</div> | |
| </div> | |
| <div class="hud query" id="query"><small>QUERY</small><span></span></div> | |
| <div class="hud controls"> | |
| <button id="replay">Replay</button><button id="rotate">Rotate</button> | |
| <small>Drag to rotate Β· scroll to zoom Β· right-drag to pan</small> | |
| </div> | |
| <div id="unavailable" hidden>WebGL is unavailable in this browser, so the interactive view cannot be shown.</div> | |
| <script> | |
| ; | |
| (() => { | |
| const D = /*DATA*/null; | |
| const $ = id => document.getElementById(id); | |
| $("title").textContent = D.title; | |
| $("subtitle").textContent = D.subtitle; | |
| $("query").hidden = !D.query; | |
| $("query").lastChild.textContent = D.query; | |
| $("stages").hidden = $("replay").hidden = !D.bev; | |
| const canvas = $("view"); | |
| const gl = canvas.getContext("webgl", {antialias: true, alpha: false}); | |
| if (!gl) { $("unavailable").hidden = false; return; } | |
| // ββ GL helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const PRECISION = "#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n"; | |
| function program(vertex, fragment) { | |
| const id = gl.createProgram(); | |
| for (const [type, source] of [[gl.VERTEX_SHADER, vertex], [gl.FRAGMENT_SHADER, PRECISION + fragment]]) { | |
| const shader = gl.createShader(type); | |
| gl.shaderSource(shader, source); | |
| gl.compileShader(shader); | |
| if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader)); | |
| gl.attachShader(id, shader); | |
| } | |
| gl.linkProgram(id); | |
| if (!gl.getProgramParameter(id, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(id)); | |
| const names = {id}; | |
| for (let i = 0; i < gl.getProgramParameter(id, gl.ACTIVE_UNIFORMS); i++) { | |
| const name = gl.getActiveUniform(id, i).name; | |
| names[name] = gl.getUniformLocation(id, name); | |
| } | |
| for (let i = 0; i < gl.getProgramParameter(id, gl.ACTIVE_ATTRIBUTES); i++) { | |
| const name = gl.getActiveAttrib(id, i).name; | |
| names[name] = gl.getAttribLocation(id, name); | |
| } | |
| return names; | |
| } | |
| function buffer(data) { | |
| const id = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, id); | |
| gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); | |
| return id; | |
| } | |
| let enabled = []; | |
| function attributes(list) { // [location, buffer, size, type, normalized, stride, offset] | |
| enabled.forEach(location => gl.disableVertexAttribArray(location)); | |
| enabled = list.map(([location, id, size, type, normalized, stride, offset]) => { | |
| gl.bindBuffer(gl.ARRAY_BUFFER, id); | |
| gl.enableVertexAttribArray(location); | |
| gl.vertexAttribPointer(location, size, type, normalized, stride, offset); | |
| return location; | |
| }); | |
| } | |
| function bytes(text) { | |
| const raw = atob(text), out = new Uint8Array(raw.length); | |
| for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); | |
| return out; | |
| } | |
| // ββ Scene: rebuilt points, BEV ground plane, zoom outlines, predicted box βββ | |
| const points = program(` | |
| attribute vec3 aPosition, aColour; | |
| uniform mat4 uMatrix; uniform vec3 uLow, uSpan; uniform float uSize, uRadius; | |
| varying vec3 vColour; | |
| void main() { | |
| vec3 p = uLow + aPosition * uSpan; | |
| gl_Position = uMatrix * vec4(p, 1.0); | |
| gl_PointSize = clamp(uSize / gl_Position.w, 1.5, 64.0); | |
| vColour = aColour * mix(1.0, 0.5, smoothstep(uRadius * 0.8, uRadius, length(p.xy))); | |
| }`, ` | |
| varying vec3 vColour; | |
| void main() { gl_FragColor = vec4(vColour, 1.0); }`); | |
| const positionBuffer = buffer(bytes(D.positions)), colourBuffer = buffer(bytes(D.colours)); | |
| const plane = program(` | |
| attribute vec2 aCorner; | |
| uniform mat4 uMatrix; uniform vec4 uBounds; | |
| varying vec2 vTexture; | |
| void main() { | |
| vTexture = vec2(aCorner.x, 1.0 - aCorner.y); | |
| gl_Position = uMatrix * vec4(mix(uBounds.xy, uBounds.zw, aCorner), 0.0, 1.0); | |
| }`, ` | |
| uniform sampler2D uTexture; varying vec2 vTexture; | |
| void main() { | |
| vec3 colour = texture2D(uTexture, vTexture).rgb; | |
| float lit = max(colour.r, max(colour.g, colour.b)); | |
| colour = mix(vec3(dot(colour, vec3(0.299, 0.587, 0.114))), colour, 0.8) * 0.45; | |
| gl_FragColor = vec4(mix(vec3(0.035, 0.047, 0.067), colour, smoothstep(0.02, 0.08, lit)), 1.0); | |
| }`); | |
| const cornerBuffer = buffer(new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1])); | |
| let texture = null, ready = !D.bev; | |
| if (D.bev) { | |
| const image = new Image(); | |
| image.onerror = () => { ready = true; }; | |
| image.onload = () => { // a power-of-two copy lets WebGL 1 build mipmaps | |
| const side = Math.min(2048, gl.getParameter(gl.MAX_TEXTURE_SIZE)), square = document.createElement("canvas"); | |
| square.width = square.height = side; | |
| square.getContext("2d").drawImage(image, 0, 0, side, side); | |
| texture = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, texture); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, square); | |
| gl.generateMipmap(gl.TEXTURE_2D); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
| ready = dirty = true; | |
| }; | |
| image.src = D.bev.image; | |
| } | |
| // Screen-space thick lines; the outer part of each stroke is a dark casing. | |
| const lines = program(` | |
| attribute vec3 aStart, aEnd; attribute vec2 aSide; | |
| uniform mat4 uMatrix; uniform vec2 uViewport; uniform vec3 uDepth; uniform float uWidth; | |
| varying vec2 vLine; | |
| void main() { | |
| vec4 a = uMatrix * vec4(aStart, 1.0), b = uMatrix * vec4(aEnd, 1.0); | |
| float near = 0.05; | |
| if (a.w < near && b.w > near) a = mix(a, b, (near - a.w) / (b.w - a.w)); | |
| if (b.w < near && a.w > near) b = mix(b, a, (near - b.w) / (a.w - b.w)); | |
| vec2 from = a.xy / a.w * uViewport * 0.5, to = b.xy / b.w * uViewport * 0.5; | |
| float length_ = distance(from, to); | |
| vec2 along = length_ > 1e-4 ? (to - from) / length_ : vec2(1.0, 0.0); | |
| vec4 p = mix(a, b, aSide.x); | |
| vec2 offset = (vec2(-along.y, along.x) * aSide.y + along * (aSide.x * 2.0 - 1.0)) * uWidth * 0.5; | |
| p.xy += offset / (uViewport * 0.5) * p.w; | |
| float depth = -p.w + uDepth.z; // pull the stroke toward the eye so it wins depth ties | |
| p.z = (uDepth.x * depth + uDepth.y) / -depth * p.w; | |
| gl_Position = p; | |
| vLine = vec2(aSide.x * length_, aSide.y); | |
| }`, ` | |
| uniform vec3 uColour; uniform float uAlpha, uCore, uWidth, uDash; | |
| varying vec2 vLine; | |
| void main() { | |
| if (uDash > 0.0 && mod(vLine.x, uDash) > uDash * 0.55) discard; | |
| float core = 1.0 - smoothstep(uCore - 1.0 / uWidth, uCore + 1.0 / uWidth, abs(vLine.y)); | |
| gl_FragColor = vec4(mix(vec3(0.07, 0.094, 0.125), uColour, core), uAlpha); | |
| }`); | |
| function strokes(segments) { | |
| const sides = [[0, -1], [1, -1], [1, 1], [0, -1], [1, 1], [0, 1]], data = new Float32Array(segments.length * 48); | |
| segments.forEach((segment, i) => sides.forEach((side, j) => data.set([...segment, ...side], (i * 6 + j) * 8))); | |
| return {id: buffer(data), count: segments.length * 6}; | |
| } | |
| function outline([x0, y0, x1, y1], z) { | |
| return [[x0, y0, z, x1, y0, z], [x1, y0, z, x1, y1, z], [x1, y1, z, x0, y1, z], [x0, y1, z, x0, y0, z]]; | |
| } | |
| const [bx0, by0, bx1, by1, bz0, bz1] = D.box; | |
| const boxEdges = strokes([...outline(D.box, bz0), ...outline(D.box, bz1), | |
| ...[[bx0, by0], [bx1, by0], [bx1, by1], [bx0, by1]].map(([x, y]) => [x, y, bz0, x, y, bz1])]); | |
| const zoomEdges = D.zooms.map(bounds => strokes(outline(bounds, 0))); | |
| const faces = program(` | |
| attribute vec3 aPosition; uniform mat4 uMatrix; | |
| void main() { gl_Position = uMatrix * vec4(aPosition, 1.0); }`, ` | |
| uniform vec4 uColour; | |
| void main() { gl_FragColor = uColour; }`); | |
| const quad = (a, b, c, d) => [...a, ...b, ...c, ...a, ...c, ...d]; | |
| const corner = (i, j, k) => [[bx0, bx1][i], [by0, by1][j], [bz0, bz1][k]]; | |
| const faceBuffer = buffer(new Float32Array([ // four sides, bottom, then top | |
| ...quad(corner(0, 0, 0), corner(1, 0, 0), corner(1, 0, 1), corner(0, 0, 1)), | |
| ...quad(corner(1, 0, 0), corner(1, 1, 0), corner(1, 1, 1), corner(1, 0, 1)), | |
| ...quad(corner(1, 1, 0), corner(0, 1, 0), corner(0, 1, 1), corner(1, 1, 1)), | |
| ...quad(corner(0, 1, 0), corner(0, 0, 0), corner(0, 0, 1), corner(0, 1, 1)), | |
| ...quad(corner(0, 0, 0), corner(0, 1, 0), corner(1, 1, 0), corner(1, 0, 0)), | |
| ...quad(corner(0, 0, 1), corner(1, 0, 1), corner(1, 1, 1), corner(0, 1, 1))])); | |
| // ββ Camera: an orbit around a target; `height` is the world height in view ββ | |
| const TAN = Math.tan(14 * Math.PI / 180); // 28 degree vertical field of view | |
| const smooth = t => { t = Math.min(Math.max(t, 0), 1); return t * t * (3 - 2 * t); }; | |
| const boxSide = Math.max(bx1 - bx0, by1 - by0, 1.2 * (bz1 - bz0)); | |
| const oblique = () => ( | |
| {x: 0, y: 0, z: (bz0 + bz1) / 2, height: Math.max(boxSide * 2, 32), elevation: 30, azimuth: 285}); | |
| function above(bounds, pad) { // north-up view straight down | |
| const aspect = canvas.width / canvas.height; | |
| return {x: (bounds[0] + bounds[2]) / 2, y: (bounds[1] + bounds[3]) / 2, z: 0, elevation: 90, azimuth: 270, | |
| height: Math.max(bounds[3] - bounds[1], (bounds[2] - bounds[0]) / aspect) * pad}; | |
| } | |
| function timeline() { // [start, end, from, to, stage, zoom outline shown from here] | |
| const last = oblique(), over = {...last, z: 0, elevation: 90, azimuth: 270, height: last.height * 1.3}; | |
| let previous = above(D.bev.bounds, 1.06), clock = 1.0; | |
| const moves = [[0, clock, previous, previous, 0, -1]]; | |
| D.zooms.forEach((bounds, i) => { | |
| const next = above(bounds, 1.1); | |
| moves.push([clock, clock + 1.7, previous, next, 1, i], [clock + 1.7, clock + 2.2, next, next, 1, i]); | |
| previous = next; | |
| clock += 2.2; | |
| }); | |
| moves.push([clock, clock + 1.6, previous, over, 1, D.zooms.length - 1], | |
| [clock + 1.6, clock + 3.8, over, last, 2, D.zooms.length - 1]); | |
| return moves; | |
| } | |
| function between(from, to, t) { | |
| const mix = key => from[key] + (to[key] - from[key]) * t; | |
| return {x: mix("x"), y: mix("y"), z: mix("z"), elevation: mix("elevation"), azimuth: mix("azimuth"), | |
| height: Math.exp(Math.log(from.height) + (Math.log(to.height) - Math.log(from.height)) * t)}; | |
| } | |
| function matrices(camera) { | |
| const az = camera.azimuth * Math.PI / 180, el = camera.elevation * Math.PI / 180; | |
| const distance = camera.height / (2 * TAN), aspect = canvas.width / canvas.height; | |
| const back = [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)]; // target to eye | |
| const up = [-Math.sin(el) * Math.cos(az), -Math.sin(el) * Math.sin(az), Math.cos(el)]; | |
| const right = [up[1] * back[2] - up[2] * back[1], up[2] * back[0] - up[0] * back[2], | |
| up[0] * back[1] - up[1] * back[0]]; | |
| const eye = [camera.x + back[0] * distance, camera.y + back[1] * distance, camera.z + back[2] * distance]; | |
| const near = Math.max(0.5, distance * 0.02), far = distance + D.extent * 3; | |
| const a = (far + near) / (near - far), b = 2 * far * near / (near - far); | |
| const row = (axis, scale) => | |
| [...axis.map(v => v * scale), -(axis[0] * eye[0] + axis[1] * eye[1] + axis[2] * eye[2]) * scale]; | |
| const rows = [row(right, 1 / TAN / aspect), row(up, 1 / TAN), row(back, a), row(back, -1)]; | |
| rows[2][3] += b; | |
| return {matrix: new Float32Array([0, 1, 2, 3].flatMap(column => rows.map(r => r[column]))), depth: [a, b]}; | |
| } | |
| // ββ State and drawing βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let camera = oblique(), intro = !!D.bev, rotating = true, dirty = true, visible = true, clock = 0, last = 0; | |
| let boxAlpha = intro ? 0 : 1, zoomsShown = intro ? -1 : D.zooms.length - 1, zoomAlpha = intro ? 0.95 : 0.35; | |
| let stage = -1; | |
| function setStage(next) { | |
| if (next === stage) return; | |
| stage = next; | |
| document.querySelectorAll(".pill").forEach(pill => { | |
| const index = Number(pill.dataset.stage); | |
| pill.className = "pill" + (index === stage ? " on" : index < stage ? " done" : ""); | |
| }); | |
| } | |
| function finishIntro() { | |
| intro = false; | |
| camera = oblique(); | |
| boxAlpha = 1; | |
| zoomsShown = D.zooms.length - 1; | |
| zoomAlpha = 0.35; | |
| setStage(2); | |
| } | |
| function advance(seconds) { | |
| if (intro) { | |
| clock += seconds; | |
| const moves = timeline(), end = moves[moves.length - 1][1]; | |
| if (clock >= end) return finishIntro(); | |
| const [start, stop, from, to, at, shown] = moves.find(move => clock < move[1]); | |
| camera = between(from, to, smooth((clock - start) / (stop - start))); | |
| zoomsShown = shown; | |
| boxAlpha = smooth((clock - (end - 0.9)) / 0.5); | |
| zoomAlpha = at < 2 ? 0.95 : 0.95 - 0.6 * smooth((clock - start) / (stop - start)); | |
| setStage(at); | |
| } else if (rotating) { | |
| camera.azimuth += 5 * seconds; | |
| } | |
| } | |
| function drawStrokes(set, view, colour, width, core, alpha, dash, bias) { | |
| attributes([[lines.aStart, set.id, 3, gl.FLOAT, false, 32, 0], [lines.aEnd, set.id, 3, gl.FLOAT, false, 32, 12], | |
| [lines.aSide, set.id, 2, gl.FLOAT, false, 32, 24]]); | |
| gl.uniformMatrix4fv(lines.uMatrix, false, view.matrix); | |
| gl.uniform2f(lines.uViewport, canvas.width, canvas.height); | |
| gl.uniform3f(lines.uDepth, view.depth[0], view.depth[1], bias); | |
| gl.uniform3fv(lines.uColour, colour); | |
| gl.uniform1f(lines.uWidth, width * canvas.height / canvas.clientHeight); | |
| gl.uniform1f(lines.uCore, core); | |
| gl.uniform1f(lines.uAlpha, alpha); | |
| gl.uniform1f(lines.uDash, dash * canvas.height / canvas.clientHeight); | |
| gl.drawArrays(gl.TRIANGLES, 0, set.count); | |
| } | |
| function draw() { | |
| const view = matrices(camera); | |
| gl.viewport(0, 0, canvas.width, canvas.height); | |
| gl.clearColor(0.035, 0.047, 0.067, 1); | |
| gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); | |
| gl.enable(gl.DEPTH_TEST); | |
| gl.depthFunc(gl.LEQUAL); | |
| gl.depthMask(true); | |
| gl.disable(gl.BLEND); | |
| if (texture) { | |
| gl.useProgram(plane.id); | |
| attributes([[plane.aCorner, cornerBuffer, 2, gl.FLOAT, false, 0, 0]]); | |
| gl.uniformMatrix4fv(plane.uMatrix, false, view.matrix); | |
| gl.uniform4fv(plane.uBounds, D.bev.bounds); | |
| gl.bindTexture(gl.TEXTURE_2D, texture); | |
| gl.drawArrays(gl.TRIANGLES, 0, 6); | |
| } | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); | |
| gl.useProgram(lines.id); | |
| gl.disable(gl.DEPTH_TEST); // zoom outlines are painted on the map, under the points | |
| for (const set of zoomEdges.slice(0, zoomsShown + 1)) { | |
| drawStrokes(set, view, [0, 0.59, 0.98], 3.5, 0.62, zoomAlpha, 0, 0); | |
| } | |
| gl.enable(gl.DEPTH_TEST); | |
| gl.disable(gl.BLEND); | |
| gl.useProgram(points.id); | |
| attributes([[points.aPosition, positionBuffer, 3, gl.UNSIGNED_SHORT, true, 0, 0], | |
| [points.aColour, colourBuffer, 3, gl.UNSIGNED_BYTE, true, 0, 0]]); | |
| gl.uniformMatrix4fv(points.uMatrix, false, view.matrix); | |
| gl.uniform3fv(points.uLow, D.low); | |
| gl.uniform3fv(points.uSpan, D.span); | |
| gl.uniform1f(points.uRadius, D.radius); | |
| gl.uniform1f(points.uSize, D.spacing * 1.7 * canvas.height / (2 * TAN)); | |
| gl.drawArrays(gl.POINTS, 0, D.count); | |
| if (boxAlpha > 0) { | |
| gl.enable(gl.BLEND); | |
| gl.depthMask(false); | |
| gl.useProgram(faces.id); | |
| attributes([[faces.aPosition, faceBuffer, 3, gl.FLOAT, false, 0, 0]]); | |
| gl.uniformMatrix4fv(faces.uMatrix, false, view.matrix); | |
| gl.uniform4f(faces.uColour, 1, 0.29, 0.29, 0.07 * boxAlpha); | |
| gl.drawArrays(gl.TRIANGLES, 0, 30); | |
| gl.uniform4f(faces.uColour, 1, 0.29, 0.29, 0.11 * boxAlpha); | |
| gl.drawArrays(gl.TRIANGLES, 30, 6); | |
| gl.useProgram(lines.id); | |
| gl.depthFunc(gl.GREATER); // strokes behind the scene stay visible as faint dashes | |
| drawStrokes(boxEdges, view, [0.77, 0.35, 0.33], 2.5, 2, 0.6 * boxAlpha, 14, 0); | |
| gl.depthFunc(gl.LEQUAL); | |
| gl.depthMask(true); | |
| drawStrokes(boxEdges, view, [1, 0.29, 0.29], 5, 0.6, boxAlpha, 0, 0.5); | |
| } | |
| $("prediction").style.opacity = boxAlpha; | |
| } | |
| function frame(now) { | |
| const seconds = Math.min((now - last) / 1000, 0.1); | |
| last = now; | |
| if (visible && (intro || rotating || dirty)) { | |
| advance(ready ? seconds : 0); | |
| draw(); | |
| dirty = false; | |
| } | |
| requestAnimationFrame(frame); | |
| } | |
| function resize() { | |
| const ratio = Math.min(window.devicePixelRatio || 1, 2); | |
| canvas.width = Math.max(1, Math.round(canvas.clientWidth * ratio)); | |
| canvas.height = Math.max(1, Math.round(canvas.clientHeight * ratio)); | |
| dirty = true; | |
| } | |
| new ResizeObserver(resize).observe(canvas); | |
| new IntersectionObserver(entries => { visible = entries[0].isIntersecting; }).observe(canvas); | |
| // ββ Interaction βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const pointers = new Map(); | |
| function takeControl() { | |
| if (intro) finishIntro(); | |
| rotating = false; | |
| $("rotate").className = ""; | |
| dirty = true; | |
| } | |
| function pan(dx, dy) { | |
| const scale = camera.height / canvas.clientHeight, az = camera.azimuth * Math.PI / 180; | |
| const forward = dy * scale / Math.max(Math.sin(camera.elevation * Math.PI / 180), 0.25); | |
| camera.x += Math.sin(az) * dx * scale - Math.cos(az) * forward; | |
| camera.y += -Math.cos(az) * dx * scale - Math.sin(az) * forward; | |
| } | |
| canvas.addEventListener("pointerdown", event => { | |
| canvas.setPointerCapture(event.pointerId); | |
| pointers.set(event.pointerId, event); | |
| canvas.className = "dragging"; | |
| takeControl(); | |
| }); | |
| canvas.addEventListener("pointermove", event => { | |
| const before = pointers.get(event.pointerId); | |
| if (!before) return; | |
| const dx = event.clientX - before.clientX, dy = event.clientY - before.clientY; | |
| if (pointers.size === 2) { // pinch to zoom, two-finger drag to pan | |
| const other = [...pointers.values()].find(pointer => pointer.pointerId !== event.pointerId); | |
| const span = pointer => Math.hypot(pointer.clientX - other.clientX, pointer.clientY - other.clientY); | |
| camera.height = Math.min(Math.max(camera.height * span(before) / Math.max(span(event), 1), 4), D.extent * 2); | |
| pan(dx / 2, dy / 2); | |
| } else if (event.buttons & 6 || event.shiftKey) { | |
| pan(dx, dy); | |
| } else { | |
| camera.azimuth -= dx * 0.35; | |
| camera.elevation = Math.min(Math.max(camera.elevation + dy * 0.35, 4), 90); | |
| } | |
| pointers.set(event.pointerId, event); | |
| dirty = true; | |
| }); | |
| for (const type of ["pointerup", "pointercancel"]) canvas.addEventListener(type, event => { | |
| pointers.delete(event.pointerId); | |
| if (!pointers.size) canvas.className = ""; | |
| }); | |
| canvas.addEventListener("wheel", event => { | |
| event.preventDefault(); | |
| takeControl(); | |
| const step = event.deltaY * (event.deltaMode === 1 ? 16 : 1); | |
| camera.height = Math.min(Math.max(camera.height * Math.exp(step * 0.0015), 4), D.extent * 2); | |
| }, {passive: false}); | |
| canvas.addEventListener("contextmenu", event => event.preventDefault()); | |
| canvas.addEventListener("dblclick", () => { camera = oblique(); dirty = true; }); | |
| $("rotate").className = "on"; | |
| $("rotate").onclick = () => { | |
| if (intro) finishIntro(); | |
| rotating = !rotating; | |
| $("rotate").className = rotating ? "on" : ""; | |
| }; | |
| $("replay").onclick = () => { | |
| intro = rotating = true; | |
| clock = boxAlpha = 0; | |
| $("rotate").className = "on"; | |
| }; | |
| resize(); | |
| requestAnimationFrame(now => { last = now; frame(now); }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |