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