diff --git a/app/demo/index.html b/app/demo/index.html
index 1638964..f3da360 100644
--- a/app/demo/index.html
+++ b/app/demo/index.html
@@ -91,33 +91,30 @@
}
.segmented {
grid-column: 2 / -1;
- display: grid;
- grid-template-columns: repeat(6, 1fr);
- border: 1px solid var(--hair);
- background: var(--panel);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
}
.segmented label {
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
font-size: 12px;
- text-align: center;
- padding: 7px 0;
- cursor: pointer;
color: var(--muted);
- border-left: 1px solid var(--hair);
- transition: background 120ms ease, color 120ms ease;
+ cursor: pointer;
+ padding: 4px 2px 5px;
+ border-bottom: 1px solid transparent;
+ transition: color 120ms ease, border-color 120ms ease;
user-select: none;
font-variant-numeric: tabular-nums;
position: relative;
}
- .segmented label:first-of-type { border-left: none; }
- .segmented label:hover { color: var(--text); background: rgba(31, 78, 95, 0.06); }
+ .segmented label:hover { color: var(--text); }
.segmented label:has(input:checked) {
- background: var(--accent);
- color: #fff;
+ color: var(--accent);
+ border-bottom-color: var(--accent);
}
.segmented label:has(input:focus-visible) {
- outline: 2px solid var(--accent);
- outline-offset: -2px;
+ outline: 1px solid var(--accent);
+ outline-offset: 2px;
}
.segmented input[type="radio"] {
position: absolute;
@@ -347,6 +344,13 @@
+
+ n frames
+
+
+
+
+
@@ -373,6 +377,11 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+// Trajectories are precomputed at the max cycle length. Toggling n-frames
+// truncates (12 is a prefix of 24 is a prefix of 48) so the same walk is
+// reused — no reroll on toggle, and the per-frame pulse stays consistent.
+const MAX_FRAMES = 48;
+
const CATEGORICAL_HEX = [
'#1f4e5f', '#c97b3f', '#8b5a9f', '#5a8560',
'#c74a5e', '#6b7d8f', '#b89f51', '#4a6fa5',
@@ -412,18 +421,6 @@ function normalize(points) {
return { positions: out, scaleFactor: 1 / maxAbs };
}
-// Box-Muller: fills an array with standard-normal samples.
-function fillRandn(arr) {
- for (let i = 0; i < arr.length; i += 2) {
- const u1 = Math.random() || 1e-12;
- const u2 = Math.random();
- const mag = Math.sqrt(-2.0 * Math.log(u1));
- arr[i] = mag * Math.cos(2 * Math.PI * u2);
- if (i + 1 < arr.length) arr[i + 1] = mag * Math.sin(2 * Math.PI * u2);
- }
- return arr;
-}
-function randnArray(n) { return fillRandn(new Float32Array(n)); }
function buildColors(labels, kind) {
const n = labels.length;
@@ -485,20 +482,42 @@ function createScene(container, dataset) {
controls.minDistance = 1.5;
controls.maxDistance = 6;
- const noiseLen = basePositions.length;
- const SNAPSHOT_MS = 900;
return {
scene, camera, renderer, controls, container, geometry,
basePositions,
scaleFactor,
- noiseA: randnArray(noiseLen),
- noiseB: randnArray(noiseLen),
- // random phase so the three scenes don't click over in lockstep
- interpStartMs: performance.now() - Math.random() * SNAPSHOT_MS,
- snapshotMs: SNAPSHOT_MS,
+ // trajectory state is populated by buildTrajectories() once at init
+ trajectories: null,
+ numFrames: 0,
+ snapshotMs: 1000 / 12,
+ // random phase so the three scenes don't loop in lockstep
+ cycleStartMs: performance.now() - Math.random() * 2000,
};
}
+// Build a cumulative random-walk trajectory for one scene.
+// Frame 0 is rest; each subsequent frame adds an independent unit-σ kick.
+// The render loop wraps frame N-1 → 0, giving a seamless cycle that ends
+// by pulling points back to base (mirrors an N-snapshot simulation's shape).
+function buildTrajectories(s, numFrames) {
+ const n = s.basePositions.length;
+ const buf = new Float32Array(numFrames * n);
+ for (let f = 1; f < numFrames; f++) {
+ const prev = (f - 1) * n;
+ const cur = f * n;
+ for (let j = 0; j < n; j += 2) {
+ const u1 = Math.random() || 1e-12;
+ const u2 = Math.random();
+ const mag = Math.sqrt(-2.0 * Math.log(u1));
+ buf[cur + j] = buf[prev + j] + mag * Math.cos(2 * Math.PI * u2);
+ if (j + 1 < n) buf[cur + j + 1] = buf[prev + j + 1] + mag * Math.sin(2 * Math.PI * u2);
+ }
+ }
+ s.trajectories = buf;
+ s.numFrames = numFrames;
+ s.cycleStartMs = performance.now() - Math.random() * s.snapshotMs * numFrames;
+}
+
function sizeScene(s) {
const rect = s.container.getBoundingClientRect();
const size = Math.max(1, Math.floor(rect.width));
@@ -548,6 +567,8 @@ async function main() {
const viz = card.querySelector('.viz');
const s = createScene(viz, ds);
sizeScene(s);
+ // Precompute at the max frame count; toggling only changes cycle length.
+ buildTrajectories(s, MAX_FRAMES);
scenes.push(s);
// Stop auto-rotate once the user interacts.
@@ -602,6 +623,24 @@ async function main() {
const jChecked = document.querySelector('input[name="j"]:checked');
applyJ(parseFloat(jChecked.value));
+ // n frames: truncates the precomputed walk. Prefix-stable (12 ⊂ 24 ⊂ 48),
+ // so toggling changes cycle length without rerolling. cycleStartMs is
+ // restaggered so the three cards don't sync up after a toggle.
+ let numFrames = 24;
+ const fInputs = document.querySelectorAll('input[name="f"]');
+ function applyF(n) {
+ numFrames = n;
+ for (const s of scenes) {
+ s.numFrames = n;
+ s.cycleStartMs = performance.now() - Math.random() * s.snapshotMs * n;
+ }
+ }
+ fInputs.forEach(input => {
+ input.addEventListener('change', (e) => applyF(parseInt(e.target.value, 10)));
+ });
+ const fChecked = document.querySelector('input[name="f"]:checked');
+ applyF(parseInt(fChecked.value, 10));
+
// Keyboard: digits jump directly, arrows step.
function selectByIndex(idx, { scroll = true } = {}) {
const entry = order[idx];
@@ -643,32 +682,37 @@ async function main() {
resizeTimer = setTimeout(() => scenes.forEach(sizeScene), 80);
});
- // Render loop.
+ // Render loop. Each scene walks through its cumulative trajectory,
+ // interpolating between consecutive frames; wraps from frame N-1 back to 0
+ // (which is rest, so the cycle naturally pulls points home).
function tick() {
requestAnimationFrame(tick);
const now = performance.now();
for (const s of scenes) {
- // Advance the A→B snapshot transition; regenerate B when we roll over.
- let t = (now - s.interpStartMs) / s.snapshotMs;
- if (t >= 1) {
- const tmp = s.noiseA; s.noiseA = s.noiseB; s.noiseB = tmp;
- fillRandn(s.noiseB);
- s.interpStartMs = now;
- t = 0;
- }
- const total = s.basePositions.length / 3;
+ const N = s.numFrames;
+ const n = s.basePositions.length;
+ const cycleMs = N * s.snapshotMs;
+ const elapsed = ((now - s.cycleStartMs) % cycleMs + cycleMs) % cycleMs;
+ const frameF = elapsed / s.snapshotMs;
+ const frameIdx = Math.floor(frameF);
+ const interpT = frameF - frameIdx;
+ const nextIdx = (frameIdx + 1) % N;
+ const aOff = frameIdx * n;
+ const bOff = nextIdx * n;
+
+ const total = n / 3;
const drawCount = s.geometry.drawRange.count;
const visibleN = Number.isFinite(drawCount) ? Math.min(drawCount, total) : total;
const limit = visibleN * 3;
const pos = s.geometry.attributes.position.array;
const base = s.basePositions;
- const a = s.noiseA, b = s.noiseB;
+ const tr = s.trajectories;
// Jitter is applied in normalized space so σ means the same thing
// across datasets — independent of the raw feature magnitudes.
const scale = jitterScale;
- const u = 1 - t;
+ const u = 1 - interpT;
for (let i = 0; i < limit; i++) {
- pos[i] = base[i] + (a[i] * u + b[i] * t) * scale;
+ pos[i] = base[i] + (tr[aOff + i] * u + tr[bOff + i] * interpT) * scale;
}
s.geometry.attributes.position.needsUpdate = true;