diff --git a/app/demo/index.html b/app/demo/index.html
index c01efa5..d97cd96 100644
--- a/app/demo/index.html
+++ b/app/demo/index.html
@@ -59,10 +59,12 @@
font-size: 15px;
}
.controls {
- display: flex;
+ display: grid;
+ grid-template-columns: auto 1fr auto;
align-items: center;
- gap: 16px;
- padding: 12px 0;
+ column-gap: 16px;
+ row-gap: 10px;
+ padding: 14px 0;
border-top: 1px solid var(--hair);
border-bottom: 1px solid var(--hair);
margin-bottom: 24px;
@@ -73,7 +75,6 @@
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
- min-width: 88px;
}
.ctl-value {
font-family: "JetBrains Mono", "SF Mono", Menlo, Monaco, monospace;
@@ -83,8 +84,8 @@
text-align: right;
font-variant-numeric: tabular-nums;
}
- #n-slider {
- flex: 1;
+ .controls input[type="range"] {
+ width: 100%;
accent-color: var(--accent);
height: 4px;
}
@@ -98,20 +99,41 @@
color: var(--text);
}
.gallery {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 260px));
+ display: flex;
gap: 20px;
- justify-content: start;
- margin-bottom: 28px;
+ 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) {
- .gallery {
- grid-template-columns: minmax(0, 320px);
- justify-content: center;
- }
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;
@@ -271,13 +293,25 @@
Three candidate generators for the embedding pipeline. Drag to rotate, scroll to zoom,
- 1 2 3 to select.
+ ← → or 1 2 3 to select.
500
+
+
+
+
+ 0.01
@@ -338,9 +372,24 @@ function normalize(points) {
out[i*3+1] = (points[i][1] - my) / maxAbs;
out[i*3+2] = (points[i][2] - mz) / maxAbs;
}
- return out;
+ // 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 };
}
+// 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;
const colors = new Float32Array(n * 3);
@@ -362,9 +411,12 @@ function buildColors(labels, kind) {
}
function createScene(container, dataset) {
- const positions = normalize(dataset.points);
+ 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));
@@ -398,7 +450,18 @@ function createScene(container, dataset) {
controls.minDistance = 1.5;
controls.maxDistance = 6;
- return { scene, camera, renderer, controls, container, geometry };
+ 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,
+ };
}
function sizeScene(s) {
@@ -468,7 +531,14 @@ async function main() {
card.classList.add('selected');
selectedId = id;
document.getElementById('selected-path').textContent = ds.path;
- document.getElementById('continue-btn').disabled = false;
+ 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.
@@ -484,15 +554,43 @@ async function main() {
slider.addEventListener('input', (e) => applyN(parseInt(e.target.value, 10)));
applyN(parseInt(slider.value, 10));
- // Keyboard: 1/2/3 select.
+ // Noise slider → rewrites positions each frame in the render loop.
+ let jitterScale = 0;
+ const jSlider = document.getElementById('j-slider');
+ const jValue = document.getElementById('j-value');
+ function applyJ(v) {
+ jitterScale = v;
+ jValue.textContent = v.toFixed(2);
+ updateContinue();
+ }
+ jSlider.addEventListener('input', (e) => applyJ(parseFloat(e.target.value)));
+ applyJ(parseFloat(jSlider.value));
+
+ // 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;
- const idx = { '1': 0, '2': 1, '3': 2 }[e.key];
- if (idx === undefined) return;
- const [id, ds] = order[idx] || [];
- if (!id) return;
- const card = gallery.children[idx];
- selectCard(id, card, ds);
+ 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.
@@ -512,7 +610,32 @@ async function main() {
// Render loop.
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 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;
+ // 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;
+ for (let i = 0; i < limit; i++) {
+ pos[i] = base[i] + (a[i] * u + b[i] * t) * scale;
+ }
+ s.geometry.attributes.position.needsUpdate = true;
+
s.controls.update();
s.renderer.render(s.scene, s.camera);
}