scrolling, animation fixes
This commit is contained in:
parent
7a6e92b31c
commit
1aa72d6412
@ -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 @@
|
||||
|
||||
<p class="lede">
|
||||
Three candidate generators for the embedding pipeline. Drag to rotate, scroll to zoom,
|
||||
<kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> to select.
|
||||
<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>
|
||||
|
||||
<label class="ctl-label" for="j-slider">noise σ</label>
|
||||
<input type="range" id="j-slider" min="0" max="5" step="1" value="1" list="j-levels">
|
||||
<datalist id="j-levels">
|
||||
<option value="0"></option>
|
||||
<option value="1"></option>
|
||||
<option value="2"></option>
|
||||
<option value="3"></option>
|
||||
<option value="4"></option>
|
||||
<option value="5"></option>
|
||||
</datalist>
|
||||
<span class="ctl-value" id="j-value">0.01</span>
|
||||
</div>
|
||||
|
||||
<div class="gallery" id="gallery">
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user