dr-sandbox/app/web/static/panel-grid.js
Michael Pilosov bdbebaa7e8 compare: click to pin a point's highlight; hover temporarily overrides
Click a point in any panel to pin its id — highlight persists after the
cursor leaves, across all linked panels. Click the same pinned point (or
empty space) to unpin. Hover still shows the point under the cursor,
briefly overriding the pinned display. Canvas cursor is now crosshair to
hint at the interaction.
2026-04-22 17:00:29 -06:00

786 lines
25 KiB
JavaScript

// 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'); }
// 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 };
}