/ challenges-old / challenge-tools
Sign in
challenges-old / challenge-tools
challenge-tools / tools / challenge_tools / viewer.js
323 lines · 20.2 KBCodeBlameHistory
f99e1d0Interactive 3D geometry view: viewer.js (raw WebGL) renders a challenge's scene.json on its results pageJeremy Magland 1/* Interactive 3D view of a challenge geometry, without dependencies (raw WebGL).
2 *
3 * Usage: <div class="cscene" data-src="scene.json"></div> ... SceneViewer.mount();
4 *
5 * scene.json: {
6 * "units": "m",
7 * "surfaces": [{"name": ..., "vertices": [[x,y,z], ...],
8 * "faces": [[a,b,c], ...] // or, for a structured grid of vertices in row-major order,
9 * "grid": [n1, n2], "periodic": true, // faces are generated; periodic closes the second index
10 * "scalar": {"name": ..., "values": [...]}}], // optional per-vertex color
11 * "points": [{"name": ..., "position": [x,y,z], "radius": r, "far": true}], // "far" points are outside the default framing
12 * "lines": [{"from": [x,y,z], "to": [x,y,z]}],
13 * "up": "z"
14 * }
15 * Left drag rotates, wheel zooms, right or shift drag pans; touch: one finger rotates, two fingers zoom and pan.
16 */
17(function () {
18 "use strict";
20 const VS = `
21 attribute vec3 aPos; attribute vec3 aNrm; attribute float aVal;
22 uniform mat4 uProj, uView; uniform vec3 uOffset; uniform float uScale, uZ;
23 varying vec3 vNrm; varying float vVal; varying vec3 vPosV;
24 void main() {
25 vec3 p = (aPos * vec3(1.0, 1.0, uZ)) * uScale + uOffset;
26 vec4 pv = uView * vec4(p, 1.0);
27 vPosV = pv.xyz; vNrm = mat3(uView) * normalize(aNrm * vec3(1.0, 1.0, 1.0 / max(uZ, 1e-6))); vVal = aVal;
28 gl_Position = uProj * pv;
29 }`;
30 const FS = `
31 precision mediump float;
32 varying vec3 vNrm; varying float vVal; varying vec3 vPosV;
33 uniform vec3 uColor; uniform float uUseMap; uniform float uAlpha;
34 vec3 cmap(float t) { // viridis, 6 anchors
35 vec3 c0 = vec3(0.267, 0.005, 0.329), c1 = vec3(0.283, 0.141, 0.458), c2 = vec3(0.254, 0.265, 0.530),
36 c3 = vec3(0.164, 0.471, 0.558), c4 = vec3(0.128, 0.567, 0.551), c5 = vec3(0.993, 0.906, 0.144);
37 t = clamp(t, 0.0, 1.0) * 5.0;
38 if (t < 1.0) return mix(c0, c1, t); if (t < 2.0) return mix(c1, c2, t - 1.0); if (t < 3.0) return mix(c2, c3, t - 2.0);
39 if (t < 4.0) return mix(c3, c4, t - 3.0); return mix(c4, c5, t - 4.0);
40 }
41 void main() {
42 vec3 n = normalize(vNrm); vec3 v = normalize(-vPosV);
43 if (dot(n, v) < 0.0) n = -n;
44 vec3 l1 = normalize(vec3(0.4, 0.5, 1.0)), l2 = normalize(vec3(-0.6, -0.3, 0.4));
45 float diff = 0.25 + 0.6 * max(dot(n, l1), 0.0) + 0.25 * max(dot(n, l2), 0.0);
46 float spec = pow(max(dot(reflect(-l1, n), v), 0.0), 40.0) * 0.25;
47 vec3 base = mix(uColor, cmap(vVal), uUseMap);
48 gl_FragColor = vec4(base * diff + spec, uAlpha);
49 }`;
51 function compile(gl, type, src) {
52 const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
53 if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
54 return s;
55 }
56 function program(gl) {
57 const p = gl.createProgram();
58 gl.attachShader(p, compile(gl, gl.VERTEX_SHADER, VS)); gl.attachShader(p, compile(gl, gl.FRAGMENT_SHADER, FS));
59 gl.linkProgram(p);
60 if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
61 return p;
62 }
64 // --- small matrix helpers (column-major 4x4)
65 function perspective(fovy, aspect, near, far) {
66 const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far);
67 return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0];
68 }
69 function lookAt(eye, target, up) {
70 let z = sub(eye, target); z = norm(z);
71 let x = norm(cross(up, z)); const y = cross(z, x);
72 return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0,
73 -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
74 }
75 function mulMV(m, v) { // 4x4 times [x,y,z,1]
76 return [m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13],
77 m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14], m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15]];
78 }
79 const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
80 const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
81 const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
82 const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
83 const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
84 const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
86 // --- geometry
87 function gridFaces(n1, n2, periodic) {
88 const f = [];
89 const m2 = periodic ? n2 : n2 - 1;
90 for (let i = 0; i < n1 - 1; i++) for (let j = 0; j < m2; j++) {
91 const j1 = (j + 1) % n2, a = i * n2 + j, b = i * n2 + j1, c = (i + 1) * n2 + j, d = (i + 1) * n2 + j1;
92 f.push([a, b, d], [a, d, c]);
93 }
94 return f;
95 }
96 function normals(verts, faces, zs) {
97 const n = new Float32Array(verts.length * 3);
98 for (const [a, b, c] of faces) {
99 const pa = verts[a], pb = verts[b], pc = verts[c];
100 const e1 = [pb[0] - pa[0], pb[1] - pa[1], (pb[2] - pa[2]) * zs], e2 = [pc[0] - pa[0], pc[1] - pa[1], (pc[2] - pa[2]) * zs];
101 const fn = cross(e1, e2);
102 for (const k of [a, b, c]) { n[3 * k] += fn[0]; n[3 * k + 1] += fn[1]; n[3 * k + 2] += fn[2]; }
103 }
104 for (let k = 0; k < verts.length; k++) {
105 const l = Math.hypot(n[3 * k], n[3 * k + 1], n[3 * k + 2]) || 1;
106 n[3 * k] /= l; n[3 * k + 1] /= l; n[3 * k + 2] /= l;
107 }
108 return n;
109 }
110 function sphere(nu, nv) {
111 const v = [], f = [];
112 for (let i = 0; i <= nu; i++) { const th = Math.PI * i / nu; for (let j = 0; j < nv; j++) { const ph = 2 * Math.PI * j / nv; v.push([Math.sin(th) * Math.cos(ph), Math.sin(th) * Math.sin(ph), Math.cos(th)]); } }
113 return { vertices: v, faces: gridFaces(nu + 1, nv, true) };
114 }
115 function edges(faces) {
116 const set = new Set(), out = [];
117 for (const [a, b, c] of faces) for (const [p, q] of [[a, b], [b, c], [c, a]]) {
118 const key = p < q ? p * 4294967296 + q : q * 4294967296 + p;
119 if (!set.has(key)) { set.add(key); out.push(p, q); }
120 }
121 return new Uint32Array(out);
122 }
124 function makeMesh(gl, verts, faces, values) {
125 const pos = new Float32Array(verts.length * 3);
126 verts.forEach((p, k) => { pos[3 * k] = p[0]; pos[3 * k + 1] = p[1]; pos[3 * k + 2] = p[2]; });
127 const idx = new Uint32Array(faces.length * 3);
128 faces.forEach((t, k) => { idx[3 * k] = t[0]; idx[3 * k + 1] = t[1]; idx[3 * k + 2] = t[2]; });
129 let val = new Float32Array(verts.length), useMap = 0, vmin = 0, vmax = 1;
130 if (values) {
131 vmin = Math.min(...values); vmax = Math.max(...values);
132 const d = vmax > vmin ? vmax - vmin : 1;
133 values.forEach((x, k) => { val[k] = (x - vmin) / d; });
134 useMap = 1;
135 }
136 const buf = (data, target) => { const b = gl.createBuffer(); gl.bindBuffer(target || gl.ARRAY_BUFFER, b); gl.bufferData(target || gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); return b; };
137 const m = { n: idx.length, verts, faces, pos: buf(pos), nrm: buf(normals(verts, faces, 1)), val: buf(val), idx: buf(idx, gl.ELEMENT_ARRAY_BUFFER),
138 edges: null, nEdges: 0, useMap, vmin, vmax, zs: 1 };
139 m.setZ = (zs) => { if (zs === m.zs) return; m.zs = zs; gl.bindBuffer(gl.ARRAY_BUFFER, m.nrm); gl.bufferData(gl.ARRAY_BUFFER, normals(verts, faces, zs), gl.STATIC_DRAW); };
140 m.ensureEdges = () => { if (!m.edges) { const e = edges(faces); m.edges = buf(e, gl.ELEMENT_ARRAY_BUFFER); m.nEdges = e.length; } };
141 return m;
142 }
144 function fmt(x) { const a = Math.abs(x); return a >= 100 || a === 0 ? x.toFixed(0) : a >= 10 ? x.toFixed(1) : a >= 1 ? x.toFixed(2) : x.toPrecision(3); }
146 function mountOne(el) {
147 const src = el.dataset.src;
148 el.classList.add("cscene-box");
149 el.innerHTML = '<div class="cscene-bar"></div><div class="cscene-canvas"><canvas></canvas><div class="cscene-labels"></div></div><div class="cscene-note muted"></div>';
150 const bar = el.querySelector(".cscene-bar"), wrap = el.querySelector(".cscene-canvas"), canvas = el.querySelector("canvas"), labels = el.querySelector(".cscene-labels"), note = el.querySelector(".cscene-note");
151 const gl = canvas.getContext("webgl", { antialias: true, alpha: false });
152 if (!gl) { note.textContent = "WebGL is not available in this browser."; return; }
153 gl.getExtension("OES_element_index_uint");
154 fetch(src).then((r) => { if (!r.ok) throw new Error(r.status + " " + r.statusText); return r.json(); }).then((scene) => setup(scene)).catch((e) => { note.textContent = "Could not load " + src + ": " + e.message; });
156 function setup(scene) {
157 const prog = program(gl);
158 gl.useProgram(prog);
159 const loc = (n) => gl.getUniformLocation(prog, n);
160 const aPos = gl.getAttribLocation(prog, "aPos"), aNrm = gl.getAttribLocation(prog, "aNrm"), aVal = gl.getAttribLocation(prog, "aVal");
161 const meshes = (scene.surfaces || []).map((s) => {
162 const faces = s.faces || gridFaces(s.grid[0], s.grid[1], !!s.periodic);
163 const m = makeMesh(gl, s.vertices, faces, s.scalar ? s.scalar.values : null);
164 m.name = s.name || ""; m.scalarName = s.scalar ? s.scalar.name : null;
165 return m;
166 });
167 const sph = sphere(12, 18);
168 const ball = makeMesh(gl, sph.vertices, sph.faces, null);
169 const points = (scene.points || []).map((p) => ({ name: p.name || "", pos: p.position, r: p.radius || 0, far: !!p.far, color: p.color || [0.85, 0.2, 0.15] }));
170 const lines = (scene.lines || []).map((l) => [l.from, l.to]);
172 // bounds of the near scene (surfaces and non-far points) and of everything
173 const bounds = (includeFar) => {
174 let lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
175 const take = (p) => { for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], p[k]); hi[k] = Math.max(hi[k], p[k]); } };
176 meshes.forEach((m) => m.verts.forEach(take));
177 points.forEach((p) => { if (includeFar || !p.far) take(p.pos); });
178 if (lo[0] === Infinity) { lo = [-1, -1, -1]; hi = [1, 1, 1]; }
179 const c = scl(add(lo, hi), 0.5), size = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) || 1;
180 return { c, size };
181 };
182 const near = bounds(false);
183 const cam = { target: near.c.slice(), dist: near.size * 1.6, az: -0.9, el: 0.45, fov: 0.6 };
184 const state = { wire: false, far: false, map: false, zs: 1 };
185 const reset = () => { const b = bounds(state.far); cam.target = b.c.slice(); cam.dist = b.size * 1.6; cam.az = -0.9; cam.el = 0.45; };
187 // controls
188 const ctrl = [];
189 const check = (label, key, onchange) => {
190 const id = "cs" + Math.random().toString(36).slice(2, 8);
191 const l = document.createElement("label"); l.innerHTML = `<input type="checkbox" id="${id}"> ${label}`;
192 l.querySelector("input").addEventListener("change", (e) => { state[key] = e.target.checked; if (onchange) onchange(); draw(); });
193 bar.appendChild(l); ctrl.push(l);
194 };
195 check("wireframe", "wire");
196 const legend = meshes.find((m) => m.useMap);
197 let lg = null;
198 if (legend) {
199 lg = document.createElement("span"); lg.className = "cscene-legend"; lg.style.display = "none";
200 lg.innerHTML = `${legend.scalarName || "value"}: ${fmt(legend.vmin)} <span class="cscene-grad"></span> ${fmt(legend.vmax)}`;
201 check("color by " + (legend.scalarName || "value"), "map", () => { lg.style.display = state.map ? "" : "none"; });
202 bar.appendChild(lg);
203 }
204 if (points.some((p) => p.far)) check("show far points (" + points.filter((p) => p.far).map((p) => p.name).join(", ") + ")", "far", reset);
205 const zsel = document.createElement("label"); zsel.innerHTML = 'z scale <select><option>1</option><option>2</option><option>4</option><option>8</option></select>';
206 zsel.querySelector("select").addEventListener("change", (e) => { state.zs = parseFloat(e.target.value); meshes.forEach((m) => m.setZ(state.zs)); draw(); });
207 bar.appendChild(zsel);
208 const rb = document.createElement("button"); rb.textContent = "reset view"; rb.addEventListener("click", () => { reset(); draw(); }); bar.appendChild(rb);
209 note.textContent = "Drag to rotate, wheel to zoom, right-drag or shift-drag to pan." + (scene.units ? " Units: " + scene.units + "." : "") + (scene.note ? " " + scene.note : "");
211 // interaction
212 let drag = null;
213 canvas.addEventListener("contextmenu", (e) => e.preventDefault());
214 canvas.addEventListener("pointerdown", (e) => { drag = { x: e.clientX, y: e.clientY, pan: e.button === 2 || e.button === 1 || e.shiftKey }; canvas.setPointerCapture(e.pointerId); });
215 canvas.addEventListener("pointerup", () => { drag = null; });
216 canvas.addEventListener("pointermove", (e) => {
217 if (!drag) return;
218 const dx = e.clientX - drag.x, dy = e.clientY - drag.y; drag.x = e.clientX; drag.y = e.clientY;
219 if (drag.pan) pan(dx, dy); else { cam.az -= dx * 0.008; cam.el = Math.max(-1.5, Math.min(1.5, cam.el + dy * 0.008)); }
220 draw();
221 });
222 canvas.addEventListener("wheel", (e) => { e.preventDefault(); cam.dist *= Math.exp(e.deltaY * 0.0015); draw(); }, { passive: false });
223 let pinch = null;
224 canvas.addEventListener("touchstart", (e) => { if (e.touches.length === 2) pinch = { d: Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY), x: (e.touches[0].clientX + e.touches[1].clientX) / 2, y: (e.touches[0].clientY + e.touches[1].clientY) / 2 }; }, { passive: true });
225 canvas.addEventListener("touchmove", (e) => {
226 if (e.touches.length === 2 && pinch) {
227 e.preventDefault();
228 const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
229 const x = (e.touches[0].clientX + e.touches[1].clientX) / 2, y = (e.touches[0].clientY + e.touches[1].clientY) / 2;
230 cam.dist *= pinch.d / d; pan(x - pinch.x, y - pinch.y); pinch = { d, x, y }; drag = null; draw();
231 }
232 }, { passive: false });
233 canvas.addEventListener("touchend", () => { pinch = null; });
234 function eye() { return add(cam.target, scl([Math.cos(cam.el) * Math.cos(cam.az), Math.cos(cam.el) * Math.sin(cam.az), Math.sin(cam.el)], cam.dist)); }
235 function pan(dx, dy) {
236 const e = eye(), f = norm(sub(cam.target, e)), r = norm(cross(f, [0, 0, 1])), u = cross(r, f);
237 const k = cam.dist * Math.tan(cam.fov / 2) * 2 / canvas.clientHeight;
238 cam.target = add(cam.target, add(scl(r, -dx * k), scl(u, dy * k)));
239 }
241 function bind(m) {
242 gl.bindBuffer(gl.ARRAY_BUFFER, m.pos); gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0);
243 gl.bindBuffer(gl.ARRAY_BUFFER, m.nrm); gl.enableVertexAttribArray(aNrm); gl.vertexAttribPointer(aNrm, 3, gl.FLOAT, false, 0, 0);
244 gl.bindBuffer(gl.ARRAY_BUFFER, m.val); gl.enableVertexAttribArray(aVal); gl.vertexAttribPointer(aVal, 1, gl.FLOAT, false, 0, 0);
245 }
246 function draw() {
247 const dpr = window.devicePixelRatio || 1, w = wrap.clientWidth, h = wrap.clientHeight;
248 if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); }
249 gl.viewport(0, 0, canvas.width, canvas.height);
250 gl.clearColor(0.97, 0.97, 0.98, 1); gl.enable(gl.DEPTH_TEST); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
251 const e = eye(), view = lookAt(e, cam.target, [0, 0, 1]), proj = perspective(cam.fov, w / h, cam.dist * 0.01, cam.dist * 100);
252 gl.uniformMatrix4fv(loc("uProj"), false, proj); gl.uniformMatrix4fv(loc("uView"), false, view);
253 gl.uniform1f(loc("uAlpha"), 1);
254 // surfaces
255 gl.uniform3f(loc("uOffset"), 0, 0, 0); gl.uniform1f(loc("uScale"), 1); gl.uniform1f(loc("uZ"), state.zs);
256 for (const m of meshes) {
257 bind(m); gl.uniform1f(loc("uUseMap"), state.map ? m.useMap : 0); gl.uniform3f(loc("uColor"), 0.80, 0.62, 0.30);
258 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.idx);
259 gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(1, 1);
260 gl.drawElements(gl.TRIANGLES, m.n, gl.UNSIGNED_INT, 0);
261 gl.disable(gl.POLYGON_OFFSET_FILL);
262 if (state.wire) { m.ensureEdges(); gl.uniform1f(loc("uUseMap"), 0); gl.uniform3f(loc("uColor"), 0.15, 0.15, 0.2); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.edges); gl.drawElements(gl.LINES, m.nEdges, gl.UNSIGNED_INT, 0); }
263 }
264 // points as spheres (a minimum screen-relative radius keeps small footprints visible)
265 bind(ball); gl.uniform1f(loc("uUseMap"), 0); gl.uniform1f(loc("uZ"), 1); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ball.idx);
266 for (const p of points) {
267 if (p.far && !state.far) continue;
268 const r = Math.max(p.r, cam.dist * 0.006);
269 gl.uniform3f(loc("uColor"), p.color[0], p.color[1], p.color[2]);
270 gl.uniform3f(loc("uOffset"), p.pos[0], p.pos[1], p.pos[2] * state.zs); gl.uniform1f(loc("uScale"), r);
271 gl.drawElements(gl.TRIANGLES, ball.n, gl.UNSIGNED_INT, 0);
272 }
273 // lines and axes
274 const segs = [];
275 for (const [a, b] of lines) if (state.far || !(points.some((p) => p.far && (same(p.pos, a) || same(p.pos, b))))) segs.push(a, b);
276 const L = near.size * 0.35;
277 segs.push(near.c, add(near.c, [L, 0, 0]), near.c, add(near.c, [0, L, 0]), near.c, add(near.c, [0, 0, L / state.zs]));
278 if (segs.length) {
279 const arr = new Float32Array(segs.length * 3); segs.forEach((p, k) => { arr[3 * k] = p[0]; arr[3 * k + 1] = p[1]; arr[3 * k + 2] = p[2]; });
280 const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, arr, gl.STREAM_DRAW);
281 gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0); gl.disableVertexAttribArray(aNrm); gl.vertexAttrib3f(aNrm, 0, 0, 1); gl.disableVertexAttribArray(aVal); gl.vertexAttrib1f(aVal, 0);
282 gl.uniform3f(loc("uOffset"), 0, 0, 0); gl.uniform1f(loc("uScale"), 1); gl.uniform1f(loc("uZ"), state.zs); gl.uniform3f(loc("uColor"), 0.35, 0.35, 0.4);
283 gl.drawArrays(gl.LINES, 0, segs.length);
284 gl.enableVertexAttribArray(aNrm); gl.enableVertexAttribArray(aVal); gl.deleteBuffer(b);
285 }
286 // labels
287 const items = points.filter((p) => state.far || !p.far).map((p) => ({ name: p.name, pos: [p.pos[0], p.pos[1], p.pos[2] * state.zs] }));
288 items.push({ name: "x", pos: add(near.c, [L, 0, 0]) }, { name: "y", pos: add(near.c, [0, L, 0]) }, { name: "z", pos: add(near.c, [0, 0, L]) });
289 labels.innerHTML = "";
290 for (const it of items) {
291 const c = mulMV(proj, mulMV(view, it.pos));
292 if (c[3] <= 0) continue;
293 const sx = (c[0] / c[3] * 0.5 + 0.5) * w, sy = (0.5 - c[1] / c[3] * 0.5) * h;
294 if (sx < -50 || sx > w + 50 || sy < -20 || sy > h + 20) continue;
295 const d = document.createElement("div"); d.className = "cscene-label"; d.textContent = it.name; d.style.left = sx + "px"; d.style.top = sy + "px"; labels.appendChild(d);
296 }
297 }
298 const same = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
299 new ResizeObserver(() => draw()).observe(wrap);
300 draw();
301 }
302 }
304 const CSS = `
305 .cscene-box { margin: 0.6em 0 1em; }
306 .cscene-bar { display: flex; flex-wrap: wrap; gap: 0.6em 1.2em; align-items: center; font-size: 0.9em; margin-bottom: 0.3em; }
307 .cscene-bar button { font: inherit; }
308 .cscene-canvas { position: relative; width: 100%; height: 480px; max-height: 70vh; border: 1px solid #ccd; border-radius: 4px; overflow: hidden; background: #f7f7f9; }
309 .cscene-canvas canvas { width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; }
310 .cscene-labels { position: absolute; inset: 0; pointer-events: none; }
311 .cscene-label { position: absolute; transform: translate(6px, -50%); font-size: 0.8em; color: #223; background: rgba(255,255,255,0.75); padding: 0 3px; border-radius: 2px; white-space: nowrap; }
312 .cscene-grad { display: inline-block; width: 90px; height: 10px; vertical-align: middle; margin: 0 4px;
313 background: linear-gradient(to right, #440154, #482878, #3e4a89, #31688e, #26828e, #1f9e89, #35b779, #6ece58, #b5de2b, #fde725); }
314 .cscene-note { font-size: 0.85em; margin-top: 0.3em; }
315 `;
317 window.SceneViewer = {
318 mount() {
319 if (!document.getElementById("cscene-css")) { const s = document.createElement("style"); s.id = "cscene-css"; s.textContent = CSS; document.head.appendChild(s); }
320 document.querySelectorAll(".cscene[data-src]").forEach(mountOne);
321 },
322 };
323})();
↑↓ move↵openescclose