compare: interpolate between frames for smooth point-trajectory motion

Default is smooth (lerp each point between adjacent frames); a motion:step
toggle preserves the snap-to-frame behaviour. Points missing in either
adjacent frame are hidden during the transition to avoid pop-in.
This commit is contained in:
Michael Pilosov 2026-04-22 14:29:24 -06:00
parent fc6aad5516
commit d0b026734a
4 changed files with 64 additions and 7 deletions

View File

@ -21,6 +21,7 @@ const playBtn = document.getElementById('cc-play');
const scrub = document.getElementById('cc-scrub');
const speedSel = document.getElementById('cc-speed');
const syncSel = document.getElementById('cc-sync');
const motionSel = document.getElementById('cc-motion');
const timeAEl = document.getElementById('cc-time').querySelector('[data-role="time-a"]');
const timeBEl = document.getElementById('cc-time').querySelector('[data-role="time-b"]');
@ -211,7 +212,43 @@ function createPanel({ slotId, panelEl, data }) {
packedN = j;
geo.attributes.position.needsUpdate = true;
geo.setDrawRange(0, packedN);
// If there was a highlighted id, reapply it so the overlay follows.
applyHighlightForCurrentFrame();
}
// Pack an interpolated frame. uLocal is a continuous index in [0, T-1].
// Points missing in either adjacent frame are skipped for the duration of
// that transition (no connect-back to the last-known position).
function setFrameInterpolated(uLocal) {
const T = data.frames.length;
if (T === 0) return;
if (uLocal <= 0) return setFrame(0);
if (uLocal >= T - 1) return setFrame(T - 1);
const f0 = Math.floor(uLocal);
const f1 = f0 + 1;
const t = uLocal - f0;
const fr0 = data.frames[f0], fr1 = data.frames[f1];
const x0 = fr0.x, y0 = fr0.y, x1 = fr1.x, y1 = fr1.y;
const ptIds = data.point_ids;
let j = 0;
for (let i = 0; i < x0.length; i++) {
const a = x0[i], b = x1[i], c = y0[i], d = y1[i];
if (a === null || a === undefined || Number.isNaN(a)
|| b === null || b === undefined || Number.isNaN(b)
|| c === null || c === undefined || Number.isNaN(c)
|| d === null || d === undefined || Number.isNaN(d)) continue;
const xi = a + (b - a) * t;
const yi = c + (d - c) * t;
positions[j * 3 + 0] = xi;
positions[j * 3 + 1] = yi;
positions[j * 3 + 2] = 0;
packedX[j] = xi;
packedY[j] = yi;
packedId[j] = ptIds[i];
j++;
}
packedN = j;
geo.attributes.position.needsUpdate = true;
geo.setDrawRange(0, packedN);
applyHighlightForCurrentFrame();
}
@ -301,6 +338,7 @@ function createPanel({ slotId, panelEl, data }) {
panelEl,
data,
setFrame,
setFrameInterpolated,
setBounds,
setHighlight,
resize,
@ -400,12 +438,18 @@ async function main() {
function applyU(u) {
u = Math.max(0, Math.min(1, u));
const smooth = motionSel.value === 'smooth';
for (const p of Object.values(panels)) {
if (!p) continue;
const T = framesOf(p);
if (T <= 0) continue;
const idx = Math.max(0, Math.min(T - 1, Math.round(u * (T - 1))));
p.setFrame(idx);
const uLocal = u * (T - 1);
if (smooth) {
p.setFrameInterpolated(uLocal);
} else {
const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal)));
p.setFrame(idx);
}
}
timeAEl.textContent = timeLabelFor(panels.a, u);
timeBEl.textContent = timeLabelFor(panels.b, u);
@ -476,6 +520,10 @@ async function main() {
speedSel.addEventListener('change', () => { lastTs = 0; });
motionSel.addEventListener('change', () => {
applyU(parseFloat(scrub.value) / SCRUB_MAX);
});
// ---- linked hover -----------------------------------------------------
function wireHover(pA, pB) {
if (!pA) return;

View File

@ -1627,7 +1627,8 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
.compare-controls .cc-time-b { color: var(--warm); }
.compare-controls .cc-speed-wrap,
.compare-controls .cc-sync-wrap {
.compare-controls .cc-sync-wrap,
.compare-controls .cc-motion-wrap {
display: inline-flex;
align-items: center;
gap: 0.35rem;

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook &middot; compare</title>
<link rel="stylesheet" href="/static/style.css?v=21" />
<link rel="stylesheet" href="/static/style.css?v=22" />
<script type="importmap">
{
"imports": {
@ -91,6 +91,14 @@
</select>
</label>
<label class="cc-motion-wrap">
<span class="cc-lbl">motion</span>
<select class="cc-motion" id="cc-motion">
<option value="smooth" selected>smooth</option>
<option value="step">step</option>
</select>
</label>
<label class="cc-sync-wrap">
<span class="cc-lbl">axes</span>
<select class="cc-sync" id="cc-sync">
@ -103,6 +111,6 @@
</section>
<script src="/static/theme.js?v=11"></script>
<script type="module" src="/static/compare.js?v=1"></script>
<script type="module" src="/static/compare.js?v=2"></script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook</title>
<link rel="stylesheet" href="/static/style.css?v=21" />
<link rel="stylesheet" href="/static/style.css?v=22" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="importmap">
{