// panel-grid.js — reusable N-panel animated-scatter grid. // // Single public export: mountPanels({ host, controls, stems }) mounts one // three.js panel per stem inside `host`, and wires the shared control strip // in `controls` to drive all of them (play/scrub, speed, motion, axes, color, // linked hover). Works identically for 1, 2, or N stems. import * as THREE from 'three'; // ---- viridis (8-stop, RGB lerp) ----------------------------------------- const VIRIDIS_HEX = [ '#440154', '#482878', '#3E4989', '#31688E', '#26828E', '#1F9E89', '#6DCD59', '#FDE725', ]; const VIRIDIS_RGB = VIRIDIS_HEX.map(h => [ parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16), ]); function lerpStops(t) { const n = VIRIDIS_RGB.length; if (t <= 0) return VIRIDIS_RGB[0]; if (t >= 1) return VIRIDIS_RGB[n - 1]; const x = t * (n - 1); const i = Math.floor(x); const f = x - i; const a = VIRIDIS_RGB[i]; const b = VIRIDIS_RGB[i + 1]; return [ Math.round(a[0] + (b[0] - a[0]) * f), Math.round(a[1] + (b[1] - a[1]) * f), Math.round(a[2] + (b[2] - a[2]) * f), ]; } function rgbToHex([r, g, b]) { const h = (v) => v.toString(16).padStart(2, '0'); return `#${h(r)}${h(g)}${h(b)}`; } function viridisColor(i, total) { if (total >= 9) return VIRIDIS_HEX[i % VIRIDIS_HEX.length]; if (total <= 1) return rgbToHex(lerpStops(0.5)); return rgbToHex(lerpStops(i / (total - 1))); } // ---- tiny helpers ------------------------------------------------------- function makeDiskTexture(size = 64) { const c = document.createElement('canvas'); c.width = c.height = size; const g = c.getContext('2d'); const r = size / 2; const grd = g.createRadialGradient(r, r, 0, r, r, r); grd.addColorStop(0.0, 'rgba(255,255,255,1)'); grd.addColorStop(0.55, 'rgba(255,255,255,1)'); grd.addColorStop(0.85, 'rgba(255,255,255,0.35)'); grd.addColorStop(1.0, 'rgba(255,255,255,0)'); g.fillStyle = grd; g.fillRect(0, 0, size, size); const tex = new THREE.CanvasTexture(c); tex.needsUpdate = true; return tex; } function rampContinuous(t, out) { const hue = (1 - t) * 215 + t * 28; const sat = 0.62; const lit = 0.50 + (t - 0.5) * 0.08; return (out || new THREE.Color()).setHSL(hue / 360, sat, lit); } const CATEGORICAL_HEX = [ '#1f4e5f', '#c97b3f', '#8b5a9f', '#5a8560', '#c74a5e', '#6b7d8f', '#b89f51', '#4a6fa5', ]; const CATEGORICAL = CATEGORICAL_HEX.map(h => new THREE.Color(h)); function buildIdColorsRGB(data) { const n = data.point_ids.length; const rgb = new Float32Array(n * 3); const labels = data.labels || []; const kind = data.label_kind || null; const hasRealLabels = kind && labels.some(v => v != null && v !== ''); if (hasRealLabels) { const tmp = new THREE.Color(); if (kind === 'categorical') { for (let i = 0; i < n; i++) { const v = labels[i]; if (v == null) continue; const c = CATEGORICAL[((v | 0) % CATEGORICAL.length + CATEGORICAL.length) % CATEGORICAL.length]; rgb[i * 3 + 0] = c.r; rgb[i * 3 + 1] = c.g; rgb[i * 3 + 2] = c.b; } } else { let lo = Infinity, hi = -Infinity; for (const v of labels) { if (v == null) continue; if (v < lo) lo = v; if (v > hi) hi = v; } const range = (hi - lo) || 1; for (let i = 0; i < n; i++) { const v = labels[i]; if (v == null) continue; rampContinuous((v - lo) / range, tmp); rgb[i * 3 + 0] = tmp.r; rgb[i * 3 + 1] = tmp.g; rgb[i * 3 + 2] = tmp.b; } } return rgb; } const frame0 = data.frames[0]; if (!frame0) return rgb; const originalPositions = []; for (let i = 0; i < n; i++) { if (frame0.x[i] != null && frame0.y[i] != null && !Number.isNaN(frame0.x[i]) && !Number.isNaN(frame0.y[i])) { originalPositions.push(i); } } originalPositions.sort((a, b) => data.point_ids[a] - data.point_ids[b]); const nOrig = originalPositions.length; const tmp = new THREE.Color(); for (let k = 0; k < nOrig; k++) { const t = nOrig > 1 ? k / (nOrig - 1) : 0.5; rampContinuous(t, tmp); const idx = originalPositions[k]; rgb[idx * 3 + 0] = tmp.r; rgb[idx * 3 + 1] = tmp.g; rgb[idx * 3 + 2] = tmp.b; } return rgb; } function readVar(name, fallback) { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } function readPanelBg(el) { const v = getComputedStyle(el).getPropertyValue('--picker-panel').trim(); return v || readVar('--panel', '#ffffff'); } function readPanelColor(el, fallback) { const v = getComputedStyle(el).getPropertyValue('--panel-color').trim(); return v || fallback; } function highlightColor() { return readVar('--alarm', '#8a3a2a'); } const PARAM_FIELDS = [ { key: 'num_points', prefix: 'N' }, { key: 'num_timesteps', prefix: 'T' }, { key: 'jitter_scale', prefix: 'J' }, { key: 'seed', prefix: 's' }, ]; function renderHeader(panelEl, data) { const m = data.meta || {}; panelEl.querySelector('[data-role="embedder"]').textContent = m.embedder || '—'; panelEl.querySelector('[data-role="generator"]').textContent = m.generator || '—'; const host = panelEl.querySelector('[data-role="params"]'); host.textContent = ''; PARAM_FIELDS.forEach(({ key, prefix }, i) => { if (i > 0) host.appendChild(document.createTextNode(' / ')); const span = document.createElement('span'); span.className = 'param'; span.dataset.key = key; span.textContent = `${prefix}${m[key] ?? '?'}`; host.appendChild(span); }); const stemLink = panelEl.querySelector('[data-role="stem-link"]'); if (stemLink && panelEl.dataset.stem) { stemLink.href = `/api/runs/${encodeURIComponent(panelEl.dataset.stem)}/frames.json`; } } function renderError(panelEl, stem, msg) { const statusEl = panelEl.querySelector('[data-role="status"]'); statusEl.style.display = ''; statusEl.classList.add('is-error'); statusEl.textContent = `could not load ${stem}: ${msg}`; panelEl.querySelector('[data-role="embedder"]').textContent = '—'; panelEl.querySelector('[data-role="generator"]').textContent = '—'; panelEl.querySelector('[data-role="params"]').textContent = '(error)'; } function markParamDiffs(panels) { if (panels.length < 2) return; const metas = panels.map(p => p.data.meta || {}); for (const { key } of PARAM_FIELDS) { const vals = metas.map(m => m[key]); const differs = vals.some(v => v !== vals[0]); for (const p of panels) { const span = p.panelEl.querySelector(`[data-role="params"] .param[data-key="${key}"]`); if (span) span.classList.toggle('diff', differs); } } for (const role of ['embedder', 'generator']) { const vals = metas.map(m => m[role]); const differs = vals.some(v => v !== vals[0]); for (const p of panels) { const span = p.panelEl.querySelector(`[data-role="${role}"]`); if (span) span.classList.toggle('diff', differs); } } } // ---- Panel factory ------------------------------------------------------ const DISK_TEX = makeDiskTexture(); function createPanel({ panelEl, data }) { const canvasEl = panelEl.querySelector('[data-role="canvas"]'); const statusEl = panelEl.querySelector('[data-role="status"]'); const scene = new THREE.Scene(); scene.background = new THREE.Color(readPanelBg(panelEl)); const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -10, 10); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); canvasEl.appendChild(renderer.domElement); const maxN = data.point_ids.length; const positions = new Float32Array(maxN * 3); const colors = new Float32Array(maxN * 3); const idColorRGB = buildIdColorsRGB(data); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geo.setDrawRange(0, 0); const matMono = new THREE.PointsMaterial({ size: 6.0, sizeAttenuation: false, map: DISK_TEX, transparent: true, alphaTest: 0.2, depthWrite: false, color: new THREE.Color(readPanelColor(panelEl, '#26828E')), }); const matRainbow = new THREE.PointsMaterial({ size: 6.0, sizeAttenuation: false, map: DISK_TEX, transparent: false, alphaTest: 0.5, vertexColors: true, }); let mat = matMono; const points = new THREE.Points(geo, mat); scene.add(points); const hiPos = new Float32Array(3); const hiGeo = new THREE.BufferGeometry(); hiGeo.setAttribute('position', new THREE.BufferAttribute(hiPos, 3)); hiGeo.setDrawRange(0, 0); const hiMat = new THREE.PointsMaterial({ size: 14.0, sizeAttenuation: false, map: DISK_TEX, transparent: true, alphaTest: 0.05, depthWrite: false, color: new THREE.Color(highlightColor()), opacity: 0.9, }); const hiPoints = new THREE.Points(hiGeo, hiMat); hiPoints.renderOrder = 1; scene.add(hiPoints); let packedN = 0; const packedX = new Float32Array(maxN); const packedY = new Float32Array(maxN); const packedId = new Int32Array(maxN); const camRect = { xmin: -1, xmax: 1, ymin: -1, ymax: 1 }; function applyCamRect(rect) { const pad = 0.05; const dx = rect.xmax - rect.xmin || 1; const dy = rect.ymax - rect.ymin || 1; const cx = (rect.xmin + rect.xmax) / 2; const cy = (rect.ymin + rect.ymax) / 2; const rx = dx * (0.5 + pad); const ry = dy * (0.5 + pad); const rect2 = canvasEl.getBoundingClientRect(); const vw = Math.max(1, rect2.width); const vh = Math.max(1, rect2.height); const aspect = vw / vh; const dataAspect = (rx * 2) / (ry * 2); let halfW, halfH; if (aspect > dataAspect) { halfH = ry; halfW = ry * aspect; } else { halfW = rx; halfH = rx / aspect; } camera.left = cx - halfW; camera.right = cx + halfW; camera.top = cy + halfH; camera.bottom = cy - halfH; camera.updateProjectionMatrix(); camRect.xmin = rect.xmin; camRect.xmax = rect.xmax; camRect.ymin = rect.ymin; camRect.ymax = rect.ymax; } function resize() { const rect = canvasEl.getBoundingClientRect(); const w = Math.max(1, Math.floor(rect.width)); const h = Math.max(1, Math.floor(rect.height)); renderer.setSize(w, h, false); applyCamRect(camRect); } function setBounds(b) { applyCamRect({ xmin: b.x[0], xmax: b.x[1], ymin: b.y[0], ymax: b.y[1] }); } function applyColorsFromTheme() { matMono.color.set(readPanelColor(panelEl, '#26828E')); hiMat.color.set(highlightColor()); scene.background = new THREE.Color(readPanelBg(panelEl)); } function setColorMode(mode) { const next = mode === 'mono' ? matMono : matRainbow; if (points.material !== next) { points.material = next; mat = next; } } function writePackedColor(j, i) { colors[j * 3 + 0] = idColorRGB[i * 3 + 0]; colors[j * 3 + 1] = idColorRGB[i * 3 + 1]; colors[j * 3 + 2] = idColorRGB[i * 3 + 2]; } function setFrame(f) { const frame = data.frames[f]; if (!frame) return; const xs = frame.x, ys = frame.y; const ptIds = data.point_ids; let j = 0; for (let i = 0; i < xs.length; i++) { const x = xs[i], y = ys[i]; if (x === null || y === null || x === undefined || y === undefined || Number.isNaN(x) || Number.isNaN(y)) continue; positions[j * 3 + 0] = x; positions[j * 3 + 1] = y; positions[j * 3 + 2] = 0; writePackedColor(j, i); packedX[j] = x; packedY[j] = y; packedId[j] = ptIds[i]; j++; } packedN = j; geo.attributes.position.needsUpdate = true; geo.attributes.color.needsUpdate = true; geo.setDrawRange(0, packedN); applyHighlightForCurrentFrame(); } function setFrameInterpolated(uLocal) { const T = data.frames.length; if (T === 0) return; if (uLocal <= 0) return setFrame(0); if (uLocal >= T - 1) return setFrame(T - 1); const f0 = Math.floor(uLocal); const f1 = f0 + 1; const t = uLocal - f0; const fr0 = data.frames[f0], fr1 = data.frames[f1]; const x0 = fr0.x, y0 = fr0.y, x1 = fr1.x, y1 = fr1.y; const ptIds = data.point_ids; let j = 0; for (let i = 0; i < x0.length; i++) { const a = x0[i], b = x1[i], c = y0[i], d = y1[i]; if (a === null || a === undefined || Number.isNaN(a) || b === null || b === undefined || Number.isNaN(b) || c === null || c === undefined || Number.isNaN(c) || d === null || d === undefined || Number.isNaN(d)) continue; const xi = a + (b - a) * t; const yi = c + (d - c) * t; positions[j * 3 + 0] = xi; positions[j * 3 + 1] = yi; positions[j * 3 + 2] = 0; writePackedColor(j, i); packedX[j] = xi; packedY[j] = yi; packedId[j] = ptIds[i]; j++; } packedN = j; geo.attributes.position.needsUpdate = true; geo.attributes.color.needsUpdate = true; geo.setDrawRange(0, packedN); applyHighlightForCurrentFrame(); } let currentHighlightId = -1; function applyHighlightForCurrentFrame() { if (currentHighlightId < 0) { hiGeo.setDrawRange(0, 0); return; } for (let i = 0; i < packedN; i++) { if (packedId[i] === currentHighlightId) { hiPos[0] = packedX[i]; hiPos[1] = packedY[i]; hiPos[2] = 0.01; hiGeo.attributes.position.needsUpdate = true; hiGeo.setDrawRange(0, 1); return; } } hiGeo.setDrawRange(0, 0); } function setHighlight(pointId) { currentHighlightId = (pointId === null || pointId === undefined) ? -1 : pointId; applyHighlightForCurrentFrame(); } function clientToWorld(clientX, clientY) { const rect = canvasEl.getBoundingClientRect(); const u = (clientX - rect.left) / Math.max(1, rect.width); const v = (clientY - rect.top) / Math.max(1, rect.height); const wx = camera.left + u * (camera.right - camera.left); const wy = camera.top - v * (camera.top - camera.bottom); return { wx, wy }; } function pickAt(clientX, clientY) { if (packedN === 0) return -1; const { wx, wy } = clientToWorld(clientX, clientY); const rect = canvasEl.getBoundingClientRect(); const worldPerPx = (camera.right - camera.left) / Math.max(1, rect.width); const pickPx = 14; const maxR = pickPx * worldPerPx; const maxR2 = maxR * maxR; let bestI = -1; let bestD2 = Infinity; for (let i = 0; i < packedN; i++) { const dx = packedX[i] - wx; const dy = packedY[i] - wy; const d2 = dx * dx + dy * dy; if (d2 < bestD2 && d2 < maxR2) { bestD2 = d2; bestI = i; } } return bestI < 0 ? -1 : packedId[bestI]; } function render() { renderer.render(scene, camera); } function dispose() { geo.dispose(); hiGeo.dispose(); matMono.dispose(); matRainbow.dispose(); hiMat.dispose(); renderer.dispose(); if (renderer.domElement.parentNode) { renderer.domElement.parentNode.removeChild(renderer.domElement); } } statusEl.style.display = 'none'; return { canvasEl, panelEl, data, setFrame, setFrameInterpolated, setColorMode, setBounds, setHighlight, resize, render, pickAt, applyColorsFromTheme, dispose, applyCamRect, get packedN() { return packedN; }, get camRect() { return camRect; }, }; } // ---- frames.json fetch -------------------------------------------------- async function fetchFrames(stem) { const res = await fetch(`/api/runs/${encodeURIComponent(stem)}/frames.json`, { cache: 'no-store' }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } // ---- public API --------------------------------------------------------- /** * Mount one three.js panel per stem into `host`, driven by the control strip * at `controls`. Returns `{ destroy }` for teardown (disposes every WebGL * context and removes the panel DOM nodes). */ export function mountPanels({ host, controls, stems }) { const tpl = document.getElementById('compare-panel-tpl'); if (!tpl) throw new Error('panel-grid: #compare-panel-tpl not found'); host.innerHTML = ''; const total = stems.length; const slots = stems.map((stem, i) => { const frag = tpl.content.cloneNode(true); const panelEl = frag.querySelector('.compare-panel'); const color = viridisColor(i, total); panelEl.style.setProperty('--panel-color', color); panelEl.dataset.stem = stem; host.appendChild(panelEl); return { stem, panelEl, color, panel: null }; }); // Build the per-panel time segments inside #cc-times. const timesHost = controls.querySelector('#cc-times'); if (timesHost) { timesHost.innerHTML = ''; slots.forEach((s, i) => { if (i > 0) { const sep = document.createElement('span'); sep.className = 'cc-time-sep'; sep.textContent = '/'; timesHost.appendChild(sep); } const seg = document.createElement('span'); seg.className = 'cc-time-seg'; seg.style.setProperty('--panel-color', s.color); seg.textContent = '—'; timesHost.appendChild(seg); s.timeEl = seg; }); } const playBtn = controls.querySelector('#cc-play'); const scrub = controls.querySelector('#cc-scrub'); const speedSel = controls.querySelector('#cc-speed'); const syncSel = controls.querySelector('#cc-sync'); const motionSel= controls.querySelector('#cc-motion'); const colorSel = controls.querySelector('#cc-color'); // Reset controls to a clean state (they may be reused across opens). if (scrub) scrub.value = '0'; if (playBtn) { playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'play'); } // Axes dropdown is meaningless for a single-panel view (nothing to lock // against; aspect is fixed by the modal dialog's own sizing). if (syncSel) syncSel.disabled = total < 2; // Parallel fetch; each panel's error is independent. const ready = Promise.allSettled(stems.map(fetchFrames)).then((results) => { results.forEach((res, i) => { const slot = slots[i]; if (res.status === 'fulfilled') { renderHeader(slot.panelEl, res.value); slot.panel = createPanel({ panelEl: slot.panelEl, data: res.value }); } else { renderError(slot.panelEl, slot.stem, res.reason?.message || String(res.reason)); } }); const live = slots.filter(s => s.panel); markParamDiffs(live.map(s => s.panel)); if (!live.length) return; for (const s of live) { s.panel.setBounds(s.panel.data.bounds); s.panel.resize(); s.panel.setFrame(0); } wireDrivers(live); }); function wireDrivers(live) { const SCRUB_MAX = 1000; function framesOf(p) { return p ? p.data.frames.length : 0; } function maxT() { let m = 2; for (const s of live) m = Math.max(m, framesOf(s.panel)); return m; } function timeLabelFor(p, uLocal) { const T = framesOf(p); if (T <= 0) return '—'; const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal))); return p.data.times?.[idx] ?? String(idx); } function applyU(u) { u = Math.max(0, Math.min(1, u)); const smooth = motionSel.value === 'smooth'; const tMax = maxT(); const uGlobal = u * (tMax - 1); for (const s of live) { const p = s.panel; const T = framesOf(p); if (T <= 0) continue; const uLocal = Math.min(uGlobal, T - 1); if (smooth) p.setFrameInterpolated(uLocal); else { const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal))); p.setFrame(idx); } if (s.timeEl) s.timeEl.textContent = timeLabelFor(p, uLocal); } } function parseAxes(value) { // Accepted: "scaled-1x1", "scaled-3x2", "locked-1x1", "locked-3x2", // plus legacy "independent" / "locked" (assume 1:1). const v = value || 'scaled-1x1'; if (v === 'independent') return { sync: 'scaled', aspect: '1 / 1' }; if (v === 'locked') return { sync: 'locked', aspect: '1 / 1' }; const [sync, ratio] = v.split('-'); const aspect = ratio === '3x2' ? '3 / 2' : '1 / 1'; return { sync: sync === 'locked' ? 'locked' : 'scaled', aspect }; } function applyAxes() { const { sync, aspect } = parseAxes(syncSel ? syncSel.value : 'scaled-3x2'); host.style.setProperty('--canvas-aspect', aspect); if (sync === 'locked' && live.length > 1) { let xmin = +Infinity, xmax = -Infinity, ymin = +Infinity, ymax = -Infinity; for (const s of live) { const b = s.panel.data.bounds; if (b.x[0] < xmin) xmin = b.x[0]; if (b.x[1] > xmax) xmax = b.x[1]; if (b.y[0] < ymin) ymin = b.y[0]; if (b.y[1] > ymax) ymax = b.y[1]; } const union = { x: [xmin, xmax], y: [ymin, ymax] }; for (const s of live) s.panel.setBounds(union); } else { for (const s of live) s.panel.setBounds(s.panel.data.bounds); } // Canvas size changes with aspect — let the panels re-fit next frame. requestAnimationFrame(() => { for (const s of live) s.panel.resize(); }); } if (syncSel) onCtl(syncSel, 'change', applyAxes); applyAxes(); let playing = false; let lastTs = 0; let u = 0; function baseMsPerFrame() { return 1600 / parseFloat(speedSel?.value || '1'); } function tick(ts) { if (stopped) return; rafId = requestAnimationFrame(tick); for (const s of live) s.panel.render(); if (!playing) { lastTs = ts; return; } if (!lastTs) { lastTs = ts; return; } const dt = ts - lastTs; lastTs = ts; if (dt <= 0) return; const perFrame = baseMsPerFrame(); const T = maxT(); u += dt / (perFrame * (T - 1)); if (u >= 1) u -= Math.floor(u); if (scrub) scrub.value = String(Math.round(u * SCRUB_MAX)); applyU(u); } rafId = requestAnimationFrame(tick); function setPlaying(v) { playing = v; lastTs = 0; if (playBtn) { playBtn.textContent = playing ? '▮▮' : '▶'; playBtn.setAttribute('aria-label', playing ? 'pause' : 'play'); } } if (playBtn) onCtl(playBtn, 'click', () => setPlaying(!playing)); if (scrub) onCtl(scrub, 'input', () => { u = parseFloat(scrub.value) / SCRUB_MAX; applyU(u); }); if (speedSel) onCtl(speedSel, 'change', () => { lastTs = 0; }); if (motionSel) onCtl(motionSel, 'change', () => { applyU(parseFloat(scrub?.value || '0') / SCRUB_MAX); }); function applyColorMode() { const mode = colorSel ? colorSel.value : 'original'; for (const s of live) s.panel.setColorMode(mode); } if (colorSel) onCtl(colorSel, 'change', applyColorMode); applyColorMode(); // Linked hover + click-to-pin. Hover shows the point under the cursor // across all panels. Click toggles a "pinned" id that stays highlighted // once the cursor leaves; hover temporarily overrides the display. let pinnedId = null; let hoveredId = null; function paintHighlight() { const id = hoveredId !== null ? hoveredId : pinnedId; for (const t of live) t.panel.setHighlight(id); } for (const s of live) { const mm = (ev) => { const id = s.panel.pickAt(ev.clientX, ev.clientY); hoveredId = id >= 0 ? id : null; paintHighlight(); }; const ml = () => { hoveredId = null; paintHighlight(); }; const mc = (ev) => { const id = s.panel.pickAt(ev.clientX, ev.clientY); pinnedId = (id < 0 || id === pinnedId) ? null : id; paintHighlight(); }; s.panel.canvasEl.addEventListener('mousemove', mm); s.panel.canvasEl.addEventListener('mouseleave', ml); s.panel.canvasEl.addEventListener('click', mc); listeners.push({ el: s.panel.canvasEl, ev: 'mousemove', fn: mm }); listeners.push({ el: s.panel.canvasEl, ev: 'mouseleave', fn: ml }); listeners.push({ el: s.panel.canvasEl, ev: 'click', fn: mc }); } // Resize + theme observers. ro = new ResizeObserver(() => { for (const s of live) s.panel.resize(); }); for (const s of live) ro.observe(s.panel.canvasEl); themeFn = () => { for (const s of live) s.panel.applyColorsFromTheme(); }; document.addEventListener('themechange', themeFn); applyU(0); } // ---- teardown bookkeeping --------------------------------------------- const listeners = []; const ctlListeners = []; let rafId = 0; let stopped = false; let ro = null; let themeFn = null; function onCtl(el, ev, fn) { el.addEventListener(ev, fn); ctlListeners.push({ el, ev, fn }); } function destroy() { stopped = true; if (rafId) cancelAnimationFrame(rafId); if (ro) ro.disconnect(); if (themeFn) document.removeEventListener('themechange', themeFn); for (const { el, ev, fn } of listeners) el.removeEventListener(ev, fn); for (const { el, ev, fn } of ctlListeners) el.removeEventListener(ev, fn); for (const s of slots) { if (s.panel) s.panel.dispose(); if (s.panelEl.parentNode) s.panelEl.parentNode.removeChild(s.panelEl); } host.innerHTML = ''; const timesHost2 = controls.querySelector('#cc-times'); if (timesHost2) timesHost2.innerHTML = ''; } return { destroy, ready }; }