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 scrub = document.getElementById('cc-scrub');
const speedSel = document.getElementById('cc-speed'); const speedSel = document.getElementById('cc-speed');
const syncSel = document.getElementById('cc-sync'); const syncSel = document.getElementById('cc-sync');
const motionSel = document.getElementById('cc-motion');
const timeAEl = document.getElementById('cc-time').querySelector('[data-role="time-a"]'); const timeAEl = document.getElementById('cc-time').querySelector('[data-role="time-a"]');
const timeBEl = document.getElementById('cc-time').querySelector('[data-role="time-b"]'); const timeBEl = document.getElementById('cc-time').querySelector('[data-role="time-b"]');
@ -211,7 +212,43 @@ function createPanel({ slotId, panelEl, data }) {
packedN = j; packedN = j;
geo.attributes.position.needsUpdate = true; geo.attributes.position.needsUpdate = true;
geo.setDrawRange(0, packedN); 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(); applyHighlightForCurrentFrame();
} }
@ -301,6 +338,7 @@ function createPanel({ slotId, panelEl, data }) {
panelEl, panelEl,
data, data,
setFrame, setFrame,
setFrameInterpolated,
setBounds, setBounds,
setHighlight, setHighlight,
resize, resize,
@ -400,13 +438,19 @@ async function main() {
function applyU(u) { function applyU(u) {
u = Math.max(0, Math.min(1, u)); u = Math.max(0, Math.min(1, u));
const smooth = motionSel.value === 'smooth';
for (const p of Object.values(panels)) { for (const p of Object.values(panels)) {
if (!p) continue; if (!p) continue;
const T = framesOf(p); const T = framesOf(p);
if (T <= 0) continue; if (T <= 0) continue;
const idx = Math.max(0, Math.min(T - 1, Math.round(u * (T - 1)))); 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); p.setFrame(idx);
} }
}
timeAEl.textContent = timeLabelFor(panels.a, u); timeAEl.textContent = timeLabelFor(panels.a, u);
timeBEl.textContent = timeLabelFor(panels.b, u); timeBEl.textContent = timeLabelFor(panels.b, u);
} }
@ -476,6 +520,10 @@ async function main() {
speedSel.addEventListener('change', () => { lastTs = 0; }); speedSel.addEventListener('change', () => { lastTs = 0; });
motionSel.addEventListener('change', () => {
applyU(parseFloat(scrub.value) / SCRUB_MAX);
});
// ---- linked hover ----------------------------------------------------- // ---- linked hover -----------------------------------------------------
function wireHover(pA, pB) { function wireHover(pA, pB) {
if (!pA) return; 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-time-b { color: var(--warm); }
.compare-controls .cc-speed-wrap, .compare-controls .cc-speed-wrap,
.compare-controls .cc-sync-wrap { .compare-controls .cc-sync-wrap,
.compare-controls .cc-motion-wrap {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook &middot; compare</title> <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"> <script type="importmap">
{ {
"imports": { "imports": {
@ -91,6 +91,14 @@
</select> </select>
</label> </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"> <label class="cc-sync-wrap">
<span class="cc-lbl">axes</span> <span class="cc-lbl">axes</span>
<select class="cc-sync" id="cc-sync"> <select class="cc-sync" id="cc-sync">
@ -103,6 +111,6 @@
</section> </section>
<script src="/static/theme.js?v=11"></script> <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> </body>
</html> </html>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook</title> <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 src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="importmap"> <script type="importmap">
{ {