restyled radio + frames radio added

This commit is contained in:
Michael Pilosov 2026-04-21 18:28:59 -06:00
parent 760bb0cdb1
commit 3d3e1e62ee

View File

@ -91,33 +91,30 @@
} }
.segmented { .segmented {
grid-column: 2 / -1; grid-column: 2 / -1;
display: grid; display: flex;
grid-template-columns: repeat(6, 1fr); justify-content: space-between;
border: 1px solid var(--hair); align-items: center;
background: var(--panel);
} }
.segmented label { .segmented label {
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace; font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
font-size: 12px; font-size: 12px;
text-align: center;
padding: 7px 0;
cursor: pointer;
color: var(--muted); color: var(--muted);
border-left: 1px solid var(--hair); cursor: pointer;
transition: background 120ms ease, color 120ms ease; padding: 4px 2px 5px;
border-bottom: 1px solid transparent;
transition: color 120ms ease, border-color 120ms ease;
user-select: none; user-select: none;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
position: relative; position: relative;
} }
.segmented label:first-of-type { border-left: none; } .segmented label:hover { color: var(--text); }
.segmented label:hover { color: var(--text); background: rgba(31, 78, 95, 0.06); }
.segmented label:has(input:checked) { .segmented label:has(input:checked) {
background: var(--accent); color: var(--accent);
color: #fff; border-bottom-color: var(--accent);
} }
.segmented label:has(input:focus-visible) { .segmented label:has(input:focus-visible) {
outline: 2px solid var(--accent); outline: 1px solid var(--accent);
outline-offset: -2px; outline-offset: 2px;
} }
.segmented input[type="radio"] { .segmented input[type="radio"] {
position: absolute; position: absolute;
@ -347,6 +344,13 @@
<label><input type="radio" name="j" value="0.15"><span>0.15</span></label> <label><input type="radio" name="j" value="0.15"><span>0.15</span></label>
<label><input type="radio" name="j" value="0.2"><span>0.20</span></label> <label><input type="radio" name="j" value="0.2"><span>0.20</span></label>
</div> </div>
<span class="ctl-label">n frames</span>
<div class="segmented" role="radiogroup" aria-label="number of frames">
<label><input type="radio" name="f" value="12"><span>12</span></label>
<label><input type="radio" name="f" value="24" checked><span>24</span></label>
<label><input type="radio" name="f" value="48"><span>48</span></label>
</div>
</div> </div>
<div class="gallery" id="gallery"> <div class="gallery" id="gallery">
@ -373,6 +377,11 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; 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 = [ const CATEGORICAL_HEX = [
'#1f4e5f', '#c97b3f', '#8b5a9f', '#5a8560', '#1f4e5f', '#c97b3f', '#8b5a9f', '#5a8560',
'#c74a5e', '#6b7d8f', '#b89f51', '#4a6fa5', '#c74a5e', '#6b7d8f', '#b89f51', '#4a6fa5',
@ -412,18 +421,6 @@ function normalize(points) {
return { positions: out, scaleFactor: 1 / maxAbs }; 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) { function buildColors(labels, kind) {
const n = labels.length; const n = labels.length;
@ -485,20 +482,42 @@ function createScene(container, dataset) {
controls.minDistance = 1.5; controls.minDistance = 1.5;
controls.maxDistance = 6; controls.maxDistance = 6;
const noiseLen = basePositions.length;
const SNAPSHOT_MS = 900;
return { return {
scene, camera, renderer, controls, container, geometry, scene, camera, renderer, controls, container, geometry,
basePositions, basePositions,
scaleFactor, scaleFactor,
noiseA: randnArray(noiseLen), // trajectory state is populated by buildTrajectories() once at init
noiseB: randnArray(noiseLen), trajectories: null,
// random phase so the three scenes don't click over in lockstep numFrames: 0,
interpStartMs: performance.now() - Math.random() * SNAPSHOT_MS, snapshotMs: 1000 / 12,
snapshotMs: SNAPSHOT_MS, // 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) { function sizeScene(s) {
const rect = s.container.getBoundingClientRect(); const rect = s.container.getBoundingClientRect();
const size = Math.max(1, Math.floor(rect.width)); const size = Math.max(1, Math.floor(rect.width));
@ -548,6 +567,8 @@ async function main() {
const viz = card.querySelector('.viz'); const viz = card.querySelector('.viz');
const s = createScene(viz, ds); const s = createScene(viz, ds);
sizeScene(s); sizeScene(s);
// Precompute at the max frame count; toggling only changes cycle length.
buildTrajectories(s, MAX_FRAMES);
scenes.push(s); scenes.push(s);
// Stop auto-rotate once the user interacts. // Stop auto-rotate once the user interacts.
@ -602,6 +623,24 @@ async function main() {
const jChecked = document.querySelector('input[name="j"]:checked'); const jChecked = document.querySelector('input[name="j"]:checked');
applyJ(parseFloat(jChecked.value)); 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. // Keyboard: digits jump directly, arrows step.
function selectByIndex(idx, { scroll = true } = {}) { function selectByIndex(idx, { scroll = true } = {}) {
const entry = order[idx]; const entry = order[idx];
@ -643,32 +682,37 @@ async function main() {
resizeTimer = setTimeout(() => scenes.forEach(sizeScene), 80); 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() { function tick() {
requestAnimationFrame(tick); requestAnimationFrame(tick);
const now = performance.now(); const now = performance.now();
for (const s of scenes) { for (const s of scenes) {
// Advance the A→B snapshot transition; regenerate B when we roll over. const N = s.numFrames;
let t = (now - s.interpStartMs) / s.snapshotMs; const n = s.basePositions.length;
if (t >= 1) { const cycleMs = N * s.snapshotMs;
const tmp = s.noiseA; s.noiseA = s.noiseB; s.noiseB = tmp; const elapsed = ((now - s.cycleStartMs) % cycleMs + cycleMs) % cycleMs;
fillRandn(s.noiseB); const frameF = elapsed / s.snapshotMs;
s.interpStartMs = now; const frameIdx = Math.floor(frameF);
t = 0; const interpT = frameF - frameIdx;
} const nextIdx = (frameIdx + 1) % N;
const total = s.basePositions.length / 3; const aOff = frameIdx * n;
const bOff = nextIdx * n;
const total = n / 3;
const drawCount = s.geometry.drawRange.count; const drawCount = s.geometry.drawRange.count;
const visibleN = Number.isFinite(drawCount) ? Math.min(drawCount, total) : total; const visibleN = Number.isFinite(drawCount) ? Math.min(drawCount, total) : total;
const limit = visibleN * 3; const limit = visibleN * 3;
const pos = s.geometry.attributes.position.array; const pos = s.geometry.attributes.position.array;
const base = s.basePositions; 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 // Jitter is applied in normalized space so σ means the same thing
// across datasets — independent of the raw feature magnitudes. // across datasets — independent of the raw feature magnitudes.
const scale = jitterScale; const scale = jitterScale;
const u = 1 - t; const u = 1 - interpT;
for (let i = 0; i < limit; i++) { 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; s.geometry.attributes.position.needsUpdate = true;