737 lines
22 KiB
HTML
737 lines
22 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Input dataset · Dimension Reduction Sandbox</title>
|
||
<style>
|
||
:root {
|
||
--bg: #fafaf7;
|
||
--panel: #f2eee4;
|
||
--text: #1a1a1a;
|
||
--muted: #6b6b6b;
|
||
--hair: #d8d3c6;
|
||
--accent: #1f4e5f;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html, body { background: var(--bg); color: var(--text); }
|
||
body {
|
||
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Charter, Georgia, serif;
|
||
font-size: 16px;
|
||
line-height: 1.55;
|
||
padding: 48px 56px 64px;
|
||
max-width: 960px;
|
||
margin: 0 auto;
|
||
}
|
||
.mono {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
}
|
||
header {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 18px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid var(--hair);
|
||
margin-bottom: 28px;
|
||
}
|
||
.section-number {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
color: var(--accent);
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
h1 {
|
||
font-size: 26px;
|
||
font-weight: 500;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.crumb {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
.lede {
|
||
color: var(--muted);
|
||
max-width: 62ch;
|
||
margin-bottom: 24px;
|
||
font-size: 15px;
|
||
}
|
||
.controls {
|
||
display: grid;
|
||
grid-template-columns: auto 1fr auto;
|
||
align-items: center;
|
||
column-gap: 16px;
|
||
row-gap: 10px;
|
||
padding: 14px 0;
|
||
border-top: 1px solid var(--hair);
|
||
border-bottom: 1px solid var(--hair);
|
||
margin-bottom: 24px;
|
||
}
|
||
.ctl-label {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
}
|
||
.ctl-value {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 13px;
|
||
color: var(--accent);
|
||
min-width: 56px;
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.controls input[type="range"] {
|
||
width: 100%;
|
||
accent-color: var(--accent);
|
||
height: 4px;
|
||
}
|
||
.segmented {
|
||
grid-column: 2 / -1;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.segmented label {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
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:hover { color: var(--text); }
|
||
.segmented label:has(input:checked) {
|
||
color: var(--accent);
|
||
border-bottom-color: var(--accent);
|
||
}
|
||
.segmented label:has(input:focus-visible) {
|
||
outline: 1px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
.segmented input[type="radio"] {
|
||
position: absolute;
|
||
opacity: 0;
|
||
width: 1px;
|
||
height: 1px;
|
||
margin: 0;
|
||
}
|
||
.lede kbd {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 12px;
|
||
background: #fff;
|
||
border: 1px solid var(--hair);
|
||
padding: 1px 6px;
|
||
margin: 0 2px;
|
||
color: var(--text);
|
||
}
|
||
.gallery {
|
||
display: flex;
|
||
gap: 20px;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
scroll-snap-type: x mandatory;
|
||
scroll-behavior: smooth;
|
||
-webkit-overflow-scrolling: touch;
|
||
padding: 2px 2px 14px;
|
||
margin: 0 -2px 20px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--hair) transparent;
|
||
}
|
||
.gallery::-webkit-scrollbar { height: 6px; }
|
||
.gallery::-webkit-scrollbar-track { background: transparent; }
|
||
.gallery::-webkit-scrollbar-thumb {
|
||
background: var(--hair);
|
||
border-radius: 3px;
|
||
}
|
||
.gallery::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||
@media (max-width: 880px) {
|
||
body { padding: 28px 20px; }
|
||
}
|
||
@media (max-width: 520px) {
|
||
header .crumb { display: none; }
|
||
h1 { font-size: 22px; }
|
||
.footer {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 12px;
|
||
}
|
||
.continue { width: 100%; }
|
||
}
|
||
.card {
|
||
flex: 0 0 260px;
|
||
scroll-snap-align: start;
|
||
border: 1px solid var(--hair);
|
||
background: var(--panel);
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||
}
|
||
.card:hover { border-color: #8f887a; }
|
||
.card.selected {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 1px var(--accent);
|
||
}
|
||
.viz {
|
||
aspect-ratio: 1 / 1;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.viz canvas {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: block;
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
.fig-label {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 12px;
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 10px;
|
||
color: var(--muted);
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
pointer-events: none;
|
||
}
|
||
.key-hint {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 10px;
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
background: rgba(250, 250, 247, 0.85);
|
||
border: 1px solid var(--hair);
|
||
padding: 1px 6px;
|
||
pointer-events: none;
|
||
}
|
||
.card.selected .key-hint {
|
||
color: var(--accent);
|
||
border-color: var(--accent);
|
||
}
|
||
.controls-hint {
|
||
position: absolute;
|
||
bottom: 8px;
|
||
right: 10px;
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 10px;
|
||
color: var(--muted);
|
||
opacity: 0;
|
||
transition: opacity 150ms ease;
|
||
pointer-events: none;
|
||
}
|
||
.card:hover .controls-hint { opacity: 0.75; }
|
||
.card-body {
|
||
padding: 16px 18px 18px;
|
||
border-top: 1px solid var(--hair);
|
||
background: var(--bg);
|
||
flex: 1;
|
||
}
|
||
.card-label {
|
||
font-weight: 500;
|
||
font-size: 16px;
|
||
margin-bottom: 3px;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
}
|
||
.card-label .dot {
|
||
display: inline-block;
|
||
width: 7px; height: 7px;
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
border: 1px solid var(--hair);
|
||
}
|
||
.card.selected .card-label .dot {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
}
|
||
.card-path {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
margin-bottom: 8px;
|
||
}
|
||
.card-desc {
|
||
font-size: 13px;
|
||
color: #4a4a4a;
|
||
line-height: 1.55;
|
||
}
|
||
.footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding-top: 20px;
|
||
border-top: 1px solid var(--hair);
|
||
gap: 24px;
|
||
}
|
||
.selection {
|
||
font-size: 14px;
|
||
color: var(--muted);
|
||
}
|
||
.selection .lbl {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 11px;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
margin-right: 8px;
|
||
}
|
||
.selection code {
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 13px;
|
||
color: var(--accent);
|
||
}
|
||
.continue {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border: none;
|
||
padding: 9px 18px;
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
.continue:disabled {
|
||
background: #e6e1d4;
|
||
color: var(--muted);
|
||
cursor: not-allowed;
|
||
}
|
||
.continue:not(:disabled):hover { background: #18404f; }
|
||
.loading {
|
||
padding: 60px 0;
|
||
text-align: center;
|
||
color: var(--muted);
|
||
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
|
||
font-size: 13px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="section-number">§1</div>
|
||
<h1>Select input dataset</h1>
|
||
<div style="flex: 1"></div>
|
||
<div class="crumb">Demo · picker</div>
|
||
</header>
|
||
|
||
<p class="lede">
|
||
Three candidate generators for the embedding pipeline. Drag to rotate, scroll to zoom,
|
||
<kbd>←</kbd> <kbd>→</kbd> or <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> to select.
|
||
</p>
|
||
|
||
<div class="controls">
|
||
<label class="ctl-label" for="n-slider">n samples</label>
|
||
<input type="range" id="n-slider" min="100" max="5000" step="100" value="500">
|
||
<span class="ctl-value" id="n-value">500</span>
|
||
|
||
<span class="ctl-label">noise σ</span>
|
||
<div class="segmented" role="radiogroup" aria-label="noise σ">
|
||
<label><input type="radio" name="j" value="0.001"><span>0.001</span></label>
|
||
<label><input type="radio" name="j" value="0.005" checked><span>0.005</span></label>
|
||
<label><input type="radio" name="j" value="0.01"><span>0.010</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">
|
||
<div class="loading">loading samples…</div>
|
||
</div>
|
||
|
||
<div class="footer">
|
||
<div class="selection">
|
||
<span class="lbl">generator</span>
|
||
<code id="selected-path">—</code>
|
||
</div>
|
||
<button class="continue" id="continue-btn" disabled>Continue →</button>
|
||
</div>
|
||
|
||
<script type="importmap">
|
||
{
|
||
"imports": {
|
||
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
||
}
|
||
}
|
||
</script>
|
||
<script type="module">
|
||
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',
|
||
];
|
||
const CATEGORICAL = CATEGORICAL_HEX.map(h => new THREE.Color(h));
|
||
|
||
function rampContinuous(t) {
|
||
// blue → teal → ochre ramp, legible against the warm panel background
|
||
const hue = (1 - t) * 215 + t * 28;
|
||
const sat = 0.62;
|
||
const lit = 0.50 + (t - 0.5) * 0.08;
|
||
return new THREE.Color().setHSL(hue / 360, sat, lit);
|
||
}
|
||
|
||
function normalize(points) {
|
||
const n = points.length;
|
||
let mx = 0, my = 0, mz = 0;
|
||
for (const p of points) { mx += p[0]; my += p[1]; mz += p[2]; }
|
||
mx /= n; my /= n; mz /= n;
|
||
let maxAbs = 1e-9;
|
||
for (const p of points) {
|
||
const a = Math.abs(p[0] - mx);
|
||
const b = Math.abs(p[1] - my);
|
||
const c = Math.abs(p[2] - mz);
|
||
if (a > maxAbs) maxAbs = a;
|
||
if (b > maxAbs) maxAbs = b;
|
||
if (c > maxAbs) maxAbs = c;
|
||
}
|
||
const out = new Float32Array(n * 3);
|
||
for (let i = 0; i < n; i++) {
|
||
out[i*3] = (points[i][0] - mx) / maxAbs;
|
||
out[i*3+1] = (points[i][1] - my) / maxAbs;
|
||
out[i*3+2] = (points[i][2] - mz) / maxAbs;
|
||
}
|
||
// scaleFactor converts raw-unit jitter to normalized-unit jitter
|
||
// so a slider value in raw coords lands correctly after normalize().
|
||
return { positions: out, scaleFactor: 1 / maxAbs };
|
||
}
|
||
|
||
|
||
function buildColors(labels, kind) {
|
||
const n = labels.length;
|
||
const colors = new Float32Array(n * 3);
|
||
if (kind === 'categorical') {
|
||
for (let i = 0; i < n; i++) {
|
||
const c = CATEGORICAL[labels[i] % CATEGORICAL.length];
|
||
colors[i*3] = c.r; colors[i*3+1] = c.g; colors[i*3+2] = c.b;
|
||
}
|
||
} else {
|
||
let lo = Infinity, hi = -Infinity;
|
||
for (const v of labels) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
||
const range = (hi - lo) || 1;
|
||
for (let i = 0; i < n; i++) {
|
||
const c = rampContinuous((labels[i] - lo) / range);
|
||
colors[i*3] = c.r; colors[i*3+1] = c.g; colors[i*3+2] = c.b;
|
||
}
|
||
}
|
||
return colors;
|
||
}
|
||
|
||
function createScene(container, dataset) {
|
||
const { positions: basePositions, scaleFactor } = normalize(dataset.points);
|
||
const colors = buildColors(dataset.labels, dataset.kind);
|
||
|
||
// mutable copy — the render loop writes jittered positions here.
|
||
const positions = new Float32Array(basePositions);
|
||
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
|
||
const material = new THREE.PointsMaterial({
|
||
size: 2.1,
|
||
sizeAttenuation: false,
|
||
vertexColors: true,
|
||
transparent: true,
|
||
opacity: 0.92,
|
||
});
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0xf2eee4);
|
||
scene.add(new THREE.Points(geometry, material));
|
||
|
||
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
|
||
camera.position.set(2.6, 1.9, 2.6);
|
||
camera.lookAt(0, 0, 0);
|
||
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
container.appendChild(renderer.domElement);
|
||
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.08;
|
||
controls.enablePan = false;
|
||
controls.autoRotate = true;
|
||
controls.autoRotateSpeed = 0.55;
|
||
controls.minDistance = 1.5;
|
||
controls.maxDistance = 6;
|
||
|
||
return {
|
||
scene, camera, renderer, controls, container, geometry,
|
||
basePositions,
|
||
scaleFactor,
|
||
// trajectory state is populated by buildTrajectories() once at init
|
||
trajectories: null,
|
||
numFrames: 0,
|
||
snapshotMs: 1000 / 12,
|
||
// holdMs pads the end of each cycle at rest, so the pulse actually
|
||
// lands (otherwise frame-0-as-rest is zero-duration and never visible).
|
||
holdMs: 200,
|
||
// shared reference so all scenes stay in lockstep; applyF() sets this
|
||
// to the same value across scenes on every n-frames toggle.
|
||
cycleStartMs: 0,
|
||
};
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
function sizeScene(s) {
|
||
const rect = s.container.getBoundingClientRect();
|
||
const size = Math.max(1, Math.floor(rect.width));
|
||
s.renderer.setSize(size, size);
|
||
s.camera.aspect = 1;
|
||
s.camera.updateProjectionMatrix();
|
||
}
|
||
|
||
async function main() {
|
||
let data;
|
||
try {
|
||
const res = await fetch('/data.json');
|
||
data = await res.json();
|
||
} catch (err) {
|
||
document.getElementById('gallery').innerHTML =
|
||
`<div class="loading">failed to load /data.json — ${err}</div>`;
|
||
return;
|
||
}
|
||
|
||
const gallery = document.getElementById('gallery');
|
||
gallery.innerHTML = '';
|
||
const scenes = [];
|
||
const order = Object.entries(data);
|
||
let selectedId = null;
|
||
|
||
order.forEach(([id, ds], i) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'card';
|
||
card.dataset.id = id;
|
||
card.innerHTML = `
|
||
<div class="viz">
|
||
<span class="fig-label">Fig. 1.${i + 1}</span>
|
||
<span class="key-hint">[${i + 1}]</span>
|
||
<span class="controls-hint">drag · scroll</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="card-label">
|
||
<span class="dot"></span>
|
||
<span>${ds.name}</span>
|
||
</div>
|
||
<div class="card-path">${ds.path}</div>
|
||
<div class="card-desc">${ds.description}</div>
|
||
</div>
|
||
`;
|
||
gallery.appendChild(card);
|
||
|
||
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.
|
||
s.controls.addEventListener('start', () => { s.controls.autoRotate = false; });
|
||
|
||
card.addEventListener('click', (e) => {
|
||
// Clicks inside the canvas that drove orbit should still select,
|
||
// but only register selection on pointer-up with no drag. Simpler:
|
||
// always select on click — OrbitControls won't emit click on drag.
|
||
selectCard(id, card, ds);
|
||
});
|
||
});
|
||
|
||
function selectCard(id, card, ds) {
|
||
document.querySelectorAll('.card').forEach(c => c.classList.remove('selected'));
|
||
card.classList.add('selected');
|
||
selectedId = id;
|
||
document.getElementById('selected-path').textContent = ds.path;
|
||
updateContinue();
|
||
}
|
||
function updateContinue() {
|
||
const btn = document.getElementById('continue-btn');
|
||
btn.disabled = !selectedId;
|
||
btn.title = selectedId ? '' : 'pick a dataset first';
|
||
}
|
||
|
||
// Sample-count slider → cheap: just change draw range per geometry.
|
||
const slider = document.getElementById('n-slider');
|
||
const nValue = document.getElementById('n-value');
|
||
function applyN(n) {
|
||
nValue.textContent = n.toLocaleString();
|
||
for (const s of scenes) {
|
||
const cap = s.geometry.attributes.position.count;
|
||
s.geometry.setDrawRange(0, Math.min(n, cap));
|
||
}
|
||
}
|
||
slider.addEventListener('input', (e) => applyN(parseInt(e.target.value, 10)));
|
||
applyN(parseInt(slider.value, 10));
|
||
|
||
// Noise is a 6-level radio group; value is the σ directly.
|
||
let jitterScale = 0;
|
||
const jInputs = document.querySelectorAll('input[name="j"]');
|
||
function applyJ(v) {
|
||
jitterScale = v;
|
||
updateContinue();
|
||
}
|
||
jInputs.forEach(input => {
|
||
input.addEventListener('change', (e) => applyJ(parseFloat(e.target.value)));
|
||
});
|
||
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;
|
||
// Shared start time keeps all three scenes in lockstep.
|
||
const start = performance.now();
|
||
for (const s of scenes) {
|
||
s.numFrames = n;
|
||
s.cycleStartMs = start;
|
||
}
|
||
}
|
||
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];
|
||
if (!entry) return;
|
||
const [id, ds] = entry;
|
||
const card = gallery.children[idx];
|
||
if (!card) return;
|
||
selectCard(id, card, ds);
|
||
if (scroll) card.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
|
||
}
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
if (/^[1-9]$/.test(e.key)) {
|
||
selectByIndex(parseInt(e.key, 10) - 1);
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
const currentIdx = order.findIndex(([id]) => id === selectedId);
|
||
const n = order.length;
|
||
const nextIdx = e.key === 'ArrowRight'
|
||
? (currentIdx < 0 ? 0 : Math.min(currentIdx + 1, n - 1))
|
||
: (currentIdx < 0 ? n - 1 : Math.max(currentIdx - 1, 0));
|
||
selectByIndex(nextIdx);
|
||
}
|
||
});
|
||
|
||
// Continue button → for now, just echo the selection.
|
||
document.getElementById('continue-btn').addEventListener('click', () => {
|
||
if (!selectedId) return;
|
||
const ds = data[selectedId];
|
||
alert(`Would continue with generator:\n${ds.path}\n\n(demo — no flow dispatched yet)`);
|
||
});
|
||
|
||
// Resize handling.
|
||
let resizeTimer = null;
|
||
window.addEventListener('resize', () => {
|
||
clearTimeout(resizeTimer);
|
||
resizeTimer = setTimeout(() => scenes.forEach(sizeScene), 80);
|
||
});
|
||
|
||
// 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) {
|
||
const N = s.numFrames;
|
||
const n = s.basePositions.length;
|
||
const walkMs = N * s.snapshotMs;
|
||
const cycleMs = walkMs + s.holdMs;
|
||
const elapsed = ((now - s.cycleStartMs) % cycleMs + cycleMs) % cycleMs;
|
||
|
||
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;
|
||
|
||
if (elapsed >= walkMs) {
|
||
// Hold zone: snap-back has landed, sit at rest until next cycle.
|
||
for (let i = 0; i < limit; i++) pos[i] = base[i];
|
||
} else {
|
||
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 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 - interpT;
|
||
for (let i = 0; i < limit; i++) {
|
||
pos[i] = base[i] + (tr[aOff + i] * u + tr[bOff + i] * interpT) * scale;
|
||
}
|
||
}
|
||
s.geometry.attributes.position.needsUpdate = true;
|
||
|
||
s.controls.update();
|
||
s.renderer.render(s.scene, s.camera);
|
||
}
|
||
}
|
||
tick();
|
||
}
|
||
|
||
main();
|
||
</script>
|
||
</body>
|
||
</html>
|