restyled radio + frames radio added
This commit is contained in:
parent
760bb0cdb1
commit
3d3e1e62ee
@ -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 @@
|
||||
<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>
|
||||
</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 class="gallery" id="gallery">
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user