- panel-grid.js (new): exports mountPanels({host, controls, stems}) → {destroy}.
Moved createPanel + shared control wiring + linked-hover + pad-to-match
time mapping out of compare.js. Stem-count-agnostic; works for 1, 2, or N.
- Panel DOM is cloned from <template id=compare-panel-tpl> on each page.
- compare.js is now a ~10-line shim: parse ?a=&b=, call mountPanels.
- Per-panel color is viridis-sampled by index/N (middle viridis for N=1,
ends-of-palette for N=2, linear lerp for N≤8, cycle at N≥9). Set as
--panel-color on the panel element; CSS reads it for tag/time-seg.
- Homepage <dialog id=run-modal> + run-modal.js hijack the 'embedding' link
(plain click → modal; meta/ctrl/middle-click still opens plotly HTML).
Dialog close disposes every panel's renderer/geometry/material.
- .compare-grid → repeat(auto-fit, minmax(360px, 1fr)) handles N=1..many,
replaces the <900px one-column media rule.
- Runs list: relabel Prefect's 'Late' state as 'Queued' — more honest
description of what the runner is doing at the concurrency cap.
757 lines
24 KiB
JavaScript
757 lines
24 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 applySync() {
|
|
const mode = syncSel ? syncSel.value : 'independent';
|
|
if (mode === '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);
|
|
}
|
|
}
|
|
if (syncSel) onCtl(syncSel, 'change', applySync);
|
|
applySync();
|
|
|
|
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 across every panel.
|
|
for (const s of live) {
|
|
const mm = (ev) => {
|
|
const id = s.panel.pickAt(ev.clientX, ev.clientY);
|
|
const hid = id >= 0 ? id : null;
|
|
for (const t of live) t.panel.setHighlight(hid);
|
|
};
|
|
const ml = () => { for (const t of live) t.panel.setHighlight(null); };
|
|
s.panel.canvasEl.addEventListener('mousemove', mm);
|
|
s.panel.canvasEl.addEventListener('mouseleave', ml);
|
|
listeners.push({ el: s.panel.canvasEl, ev: 'mousemove', fn: mm });
|
|
listeners.push({ el: s.panel.canvasEl, ev: 'mouseleave', fn: ml });
|
|
}
|
|
|
|
// 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 };
|
|
}
|