dr-sandbox/app/demo/index.html
2026-04-21 18:43:50 -06:00

730 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>&nbsp;<kbd></kbd> or <kbd>1</kbd>&nbsp;<kbd>2</kbd>&nbsp;<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"><span>0.00</span></label>
<label><input type="radio" name="j" value="0.01" checked><span>0.01</span></label>
<label><input type="radio" name="j" value="0.05"><span>0.05</span></label>
<label><input type="radio" name="j" value="0.1"><span>0.10</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>
</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,
// 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));
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 && jitterScale > 0);
btn.title = !selectedId
? 'pick a dataset first'
: (jitterScale <= 0 ? 'noise σ must be > 0 to simulate' : '');
}
// 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;
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];
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 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 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>