Keep a transition's pace through new steps and pans (#309)

A wheel step in the middle of a transition restarted the ease-out
curve at full speed, so a run of quick steps kicked at each one.
A pan called finish() first and made the zoom jump to its target.
Both made the zoom and the gliding lists feel jerky.

- A new step starts from the speed the view already has and then
  eases out. Quick steps in one direction move in one stroke. A step
  the other way still starts from rest.
- A pan takes the transition along. The zoom goes on while dragging
  and lands where it was heading, moved by the pan. This covers all
  eight pan places: scene and container, graph, plugin board,
  texture and pixel art, timeline, pattern, song and sound.
- finish() is gone, it has no callers left. Dragging the scrollbar,
  a jump to a row, another document or a new view size still end
  the transition as before.

Found: on a second step the old curve jumped to 2.47 times the
speed the view had just before.

Tests:
- npm run check on stein2: 299 files, 4598 tests (5 new)
- e2e 46 on stein2: ALL PASSED, 24 checks (1 new). A pan by 40/30
  px during a 1000 ms zoom step leaves it moving (2.33 on the way
  from 2 to 3), it lands on 3 with the world point moved by the pan.
- Counter-checks: with the pace reset to 3 the new unit test fails
  with 2.47 instead of at most 1.1. Without carry the pan test fails.
- melektaus-atx went offline mid-run, so both ran on stein2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 23:08:43 +02:00
1 parent be41b1f0d7
commit 597367c804
12 files changed
+307 -72

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2017 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2018 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+7 -3
View File
@@ -554,8 +554,9 @@ export class TimelineCanvas extends Widget {
const local = this.toLocal(e.global);
const point = { x: local.x, y: local.y };
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
this.tween.finish();
this.panning = { pointer: point, panX: this.view.panX };
this.tween.target(() => {
this.panning = { pointer: point, panX: this.view.panX };
});
e.stopPropagation();
return;
}
@@ -616,7 +617,10 @@ export class TimelineCanvas extends Widget {
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
if (this.panning) {
this.view = clampPan({ ...this.view, panX: this.panning.panX + (local.x - this.panning.pointer.x) });
const { panning } = this;
this.tween.carry(() => {
this.view = clampPan({ ...this.view, panX: panning.panX + (local.x - panning.pointer.x) });
});
this.redraw();
return;
}
+11 -5
View File
@@ -593,8 +593,9 @@ export class GraphCanvas extends Widget {
// The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason.
const startPan = () => {
this.tween.finish();
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
this.tween.target(() => {
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
});
};
if (e.button === 1) {
startPan();
@@ -782,9 +783,14 @@ export class GraphCanvas extends Widget {
this.trackIncoming(dragged, e);
return;
}
if (this.panning) {
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
const { panning } = this;
if (panning) {
this.tween.carry(() => {
this.pan = {
x: panning.panX + (e.global.x - panning.startX),
y: panning.panY + (e.global.y - panning.startY),
};
});
this.applyView();
return;
}
+6 -3
View File
@@ -76,13 +76,16 @@ export function panAndZoom(
): void {
let panning: { start: Point; pan: Point } | null = null;
surface.on('pointerdown', (e: FederatedPointerEvent) => {
tween.finish();
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
tween.target(() => {
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
});
});
surface.on('globalpointermove', (e: FederatedPointerEvent) => {
if (!panning) return;
const { start, pan } = panning;
set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan');
tween.carry(() =>
set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan'),
);
});
surface.on('pointerup', () => (panning = null));
surface.on('pointerupoutside', () => (panning = null));
+20 -14
View File
@@ -566,9 +566,11 @@ export class PianoRoll extends Widget {
e.stopPropagation();
ui().focus.focus(this);
if (e.button === 1) {
this.timeTween.finish();
this.rowTween.finish();
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
this.timeTween.target(() =>
this.rowTween.target(() => {
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
}),
);
return;
}
const g = this.inGrid(p);
@@ -660,17 +662,21 @@ export class PianoRoll extends Widget {
const free = e.altKey || e.shiftKey;
switch (gesture.kind) {
case 'pan':
this.view = clampRows(
clampTimePan(
{
...this.view,
panX: gesture.panX + (local.x - gesture.pointer.x),
panY: gesture.panY + (local.y - gesture.pointer.y),
},
this.gridWidth,
this.earliest,
),
this.gridHeight,
this.timeTween.carry(() =>
this.rowTween.carry(() => {
this.view = clampRows(
clampTimePan(
{
...this.view,
panX: gesture.panX + (local.x - gesture.pointer.x),
panY: gesture.panY + (local.y - gesture.pointer.y),
},
this.gridWidth,
this.earliest,
),
this.gridHeight,
);
}),
);
break;
case 'key': {
+20 -14
View File
@@ -756,9 +756,11 @@ export class SongTimeline extends Widget {
e.stopPropagation();
ui().focus.focus(this);
if (e.button === 1) {
this.timeTween.finish();
this.trackTween.finish();
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
this.timeTween.target(() =>
this.trackTween.target(() => {
this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
}),
);
return;
}
if (this.take) return;
@@ -911,17 +913,21 @@ export class SongTimeline extends Widget {
const picked = new Set(this.pickedIds);
switch (gesture.kind) {
case 'pan':
this.view = clampTracks(
clampTimePan(
{
...this.view,
panX: gesture.panX + (local.x - gesture.pointer.x),
panY: gesture.panY + (local.y - gesture.pointer.y),
},
this.lanesWidth,
),
asset.tracks.length,
this.lanesHeight,
this.timeTween.carry(() =>
this.trackTween.carry(() => {
this.view = clampTracks(
clampTimePan(
{
...this.view,
panX: gesture.panX + (local.x - gesture.pointer.x),
panY: gesture.panY + (local.y - gesture.pointer.y),
},
this.lanesWidth,
),
asset.tracks.length,
this.lanesHeight,
);
}),
);
break;
case 'loop': {
+10 -3
View File
@@ -380,8 +380,9 @@ export class WaveCanvas extends Widget {
e.stopPropagation();
ui().focus.focus(this);
if (e.button === 1) {
this.timeTween.finish();
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
this.timeTween.target(() => {
this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
});
return;
}
if (e.button !== 0 || p.x < 0) return;
@@ -410,7 +411,13 @@ export class WaveCanvas extends Widget {
return;
}
if (g.kind === 'pan') {
this.view = clampWave({ ...this.view, panX: g.panX + p.x - g.pointerX }, wave.duration, this.areaWidth);
this.timeTween.carry(() => {
this.view = clampWave(
{ ...this.view, panX: g.panX + p.x - g.pointerX },
wave.duration,
this.areaWidth,
);
});
this.needsFit = false;
this.redraw();
return;
+6 -3
View File
@@ -142,8 +142,9 @@ export abstract class SheetCanvas<A> extends Widget {
const local = this.toLocal(e.global);
const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y });
if (e.button === 1 || pans) {
this.tween.finish();
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
this.tween.target(() => {
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
});
e.stopPropagation();
return;
}
@@ -160,7 +161,9 @@ export abstract class SheetCanvas<A> extends Widget {
this.move({ x: local.x, y: local.y }, e);
return;
}
this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y));
this.tween.carry(() =>
this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y)),
);
this.panned();
}
+116 -14
View File
@@ -13,8 +13,60 @@ export interface ViewMap {
/** Quick at first and gentle at the end: the zoom answers the wheel at once and settles softly. */
export function easeOut(t: number): number {
return glide(t, 3);
}
/** The steepest start `glide` takes without running past its end. */
const STEEPEST = 4;
/**
* The share of the way at `t`, setting off at `speed` (shares per duration) and settling with
* neither speed nor pull left at the end. At 3 it is the cubic ease-out, at 0 it starts at rest.
*/
export function glide(t: number, speed: number): number {
const rest = 1 - Math.min(1, Math.max(0, t));
return 1 - rest * rest * rest;
return 1 - (4 - speed) * rest ** 3 - (speed - 3) * rest ** 4;
}
/** How fast `glide` moves at `t`, in shares per duration. */
export function glideSlope(t: number, speed: number): number {
const rest = 1 - Math.min(1, Math.max(0, t));
return 3 * (4 - speed) * rest ** 2 + 4 * (speed - 3) * rest ** 3;
}
/**
* The speed a new way from `from` to `to`, `ms` long, sets off at to go on as fast as the old one
* moved: `rate` shares of `old` per ms, at share `at`. Along the scale where the new way zooms,
* along its line where it slides. Moving against the new way starts it at rest.
*/
export function carriedSpeed(
old: { from: ViewMap; to: ViewMap },
at: number,
rate: number,
from: ViewMap,
to: ViewMap,
ms: number,
): number {
const h = at < 0.5 ? 1e-4 : -1e-4;
const a = tweenView(old.from, old.to, at);
const b = tweenView(old.from, old.to, at + h);
const per = (p: number, q: number) => ((q - p) / h) * rate * ms;
const zoom = Math.log(to.scale / from.scale);
let speed: number;
if (Math.abs(zoom) > 1e-9) speed = per(Math.log(a.scale), Math.log(b.scale)) / zoom;
else {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = dx * dx + dy * dy;
speed = length > 0 ? (per(a.x, b.x) * dx + per(a.y, b.y) * dy) / length : 0;
}
return Number.isFinite(speed) ? Math.max(0, speed) : 0;
}
/** The move that takes the screen of view `a` to that of `b`, applied to `view`. */
function moved(view: ViewMap, a: ViewMap, b: ViewMap): ViewMap {
const k = b.scale / a.scale;
return { scale: view.scale * k, x: b.x + (view.x - a.x) * k, y: b.y + (view.y - a.y) * k };
}
/**
@@ -67,11 +119,19 @@ const tickerClock: TweenClock = {
* Shows a zoom or a Fit as a short transition instead of a jump (#309). The surface changes its view
* as it always did, and the tween shows the way from what was on screen to it.
*
* Changing the view by other means – a pan, another document, a resize – ends the transition where
* it stands: the tween notices that the view is no longer the one it left and lets go.
* A pan made through `carry` moves the transition along with it. Changing the view by other means –
* another document, a resize, a jump – ends the transition where it stands: the tween notices that
* the view is no longer the one it left and lets go.
*/
export class ViewTween {
private run: { from: ViewMap; to: ViewMap; start: number; ms: number; left: ViewMap } | null = null;
private run: {
from: ViewMap;
to: ViewMap;
start: number;
ms: number;
speed: number;
left: ViewMap;
} | null = null;
private readonly tick = () => this.step();
constructor(
@@ -93,7 +153,7 @@ export class ViewTween {
/**
* Carries out `change` and shows the way to the view it leaves. A change made during a transition
* starts from where that one was heading, so quick wheel steps add up to exactly as many steps,
* while the way to the new view starts from what is on screen.
* while the way to the new view starts from what is on screen, as fast as the screen moved.
*/
change(change: () => void): void {
const shown = this.view.read();
@@ -101,7 +161,7 @@ export class ViewTween {
if (running) this.view.write(running.to);
change();
const to = this.view.read();
const ms = this.duration();
let ms = this.duration();
if (ms <= 0 || near(shown, to)) {
this.stop();
if (running) this.view.draw();
@@ -111,18 +171,60 @@ export class ViewTween {
this.view.write(shown);
return;
}
if (!running) this.clock.add(this.tick);
const now = this.clock.now();
let speed = 3;
if (running) {
const t = Math.min(1, (now - running.start) / running.ms);
const rate = glideSlope(t, running.speed) / running.ms;
speed = carriedSpeed(running, glide(t, running.speed), rate, shown, to, ms);
// Faster than the rest of the way allows without running past it: get there sooner instead.
if (speed > STEEPEST) {
ms *= STEEPEST / speed;
speed = STEEPEST;
}
} else this.clock.add(this.tick);
// Not drawn here: the next frame draws the first step of the way before it is rendered.
this.view.write(shown);
this.run = { from: shown, to, start: this.clock.now(), ms, left: this.view.read() };
this.run = { from: shown, to, start: now, ms, speed, left: this.view.read() };
requestRender();
}
/** Jumps to where the transition was heading and draws it – before a pan takes the view. */
finish(): void {
if (!this.run) return;
this.land();
this.view.draw();
/**
* Runs `fn` on the view a transition is heading for rather than the one on screen – where a pan
* takes its hold, so a pan made through `carry` moves on from there.
*/
target(fn: () => void): void {
const run = this.run;
if (!run) {
fn();
return;
}
const shown = this.view.read();
this.view.write(run.to);
fn();
this.view.write(shown);
}
/**
* Carries out a pan during a transition without ending it: `fn` moves the view the transition is
* heading for, and the view on screen and the rest of the way move with it.
*/
carry(fn: () => void): void {
const run = this.run;
if (!run) {
fn();
return;
}
const shown = this.view.read();
this.view.write(run.to);
fn();
const to = this.view.read();
const left = moved(shown, run.to, to);
run.from = moved(run.from, run.to, to);
run.to = to;
this.view.write(left);
run.left = this.view.read();
requestRender();
}
/** Puts the view where the transition was heading, without drawing – before it is saved. */
@@ -141,7 +243,7 @@ export class ViewTween {
return;
}
const t = (this.clock.now() - run.start) / run.ms;
this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, easeOut(t)));
this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, glide(t, run.speed)));
run.left = this.view.read();
if (t >= 1) this.stop();
this.view.draw();
+10 -6
View File
@@ -133,17 +133,21 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
}
protected startPan(e: FederatedPointerEvent): void {
this.tween.finish();
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
this.tween.target(() => {
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
});
e.stopPropagation();
}
/** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */
protected panTo(e: FederatedPointerEvent): boolean {
if (!this.panning) return false;
const scale = this.worldScale();
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / scale;
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / scale;
const { panning } = this;
if (!panning) return false;
this.tween.carry(() => {
const scale = this.worldScale();
this.view.x = panning.viewX - (e.global.x - panning.startX) / scale;
this.view.y = panning.viewY - (e.global.y - panning.startY) / scale;
});
this.moveCamera();
return true;
}
+78 -5
View File
@@ -11,7 +11,10 @@ import { zoomedAbout } from '../../src/editor/graph/graphView';
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
import {
axisTween,
carriedSpeed,
easeOut,
glide,
glideSlope,
scrollTween,
tweenView,
type ViewMap,
@@ -178,7 +181,7 @@ describe('the transition of a view', () => {
expect(clock.attached).toBe(0);
});
it('lands on the target without drawing, and finishes by drawing it', () => {
it('lands on the target without drawing', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
@@ -188,12 +191,82 @@ describe('the transition of a view', () => {
tween.land();
expect(s.view.scale).toBe(2);
expect(s.draws).toBe(draws);
tween.change(double(s));
tween.finish();
expect(s.view.scale).toBe(4);
expect(s.draws).toBe(draws + 1);
expect(clock.attached).toBe(0);
});
/** How fast the scale moves, in ratio per ms, over the next `ms`. */
const pace = (s: { view: ViewMap }, clock: ReturnType<typeof fakeClock>, ms: number) => {
const before = s.view.scale;
clock.advance(ms);
return Math.log(s.view.scale / before) / ms;
};
it('goes on at the pace it had when a quick step comes in, instead of starting over', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
tween.change(double(s));
clock.advance(48);
const before = pace(s, clock, 2);
tween.change(double(s));
const after = pace(s, clock, 2);
expect(after / before).toBeGreaterThan(0.9);
expect(after / before).toBeLessThan(1.1);
clock.advance(200);
expect(s.view.scale).toBe(4);
});
it('pans along during a transition: the rest of the way moves with the pan, and it lands moved', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
tween.change(double(s));
clock.advance(60);
let hold = { x: 0, y: 0 };
tween.target(() => (hold = { x: s.view.x, y: s.view.y }));
expect(hold).toEqual({ x: 0, y: 0 });
const shown = { ...s.view };
tween.carry(() => (s.view = { ...s.view, x: hold.x + 30, y: hold.y - 10 }));
expect(s.view).toEqual({ ...shown, x: shown.x + 30, y: shown.y - 10 });
expect(tween.moving).toBe(true);
clock.advance(16);
expect(tween.moving).toBe(true);
expect(s.view.scale).toBeGreaterThan(shown.scale);
clock.advance(200);
expect(s.view).toEqual({ scale: 2, x: 30, y: -10 });
});
});
describe('the curve of a transition', () => {
it('starts at the speed it is given and settles at the end', () => {
for (const speed of [0, 1.5, 3, 4]) {
expect(glide(0, speed)).toBe(0);
expect(glide(1, speed)).toBe(1);
expect(glideSlope(0, speed)).toBeCloseTo(speed, 12);
expect(glideSlope(1, speed)).toBe(0);
expect((glide(1e-6, speed) - glide(0, speed)) / 1e-6).toBeCloseTo(speed, 4);
}
});
it('never runs back or past its end up to the steepest start', () => {
for (const speed of [0, 2, 4]) {
let last = 0;
for (let i = 1; i <= 100; i++) {
const share = glide(i / 100, speed);
expect(share).toBeGreaterThanOrEqual(last);
expect(share).toBeLessThanOrEqual(1);
last = share;
}
}
});
it('carries the pace of a slide over to a longer slide the same way, and starts at rest against it', () => {
const old = { from: { scale: 1, x: 0, y: 0 }, to: { scale: 1, x: 100, y: 0 } };
const from = tweenView(old.from, old.to, 0.5);
// 0.01 of 100 px per ms is 1 px/ms; over 200 ms on a way of 150 px that is a speed of 4/3.
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: 200, y: 0 }, 200)).toBeCloseTo(4 / 3, 6);
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: -50, y: 0 }, 200)).toBe(0);
});
});
describe('a view whose axes zoom apart (#377)', () => {
+22 -1
View File
@@ -471,7 +471,28 @@ export async function topbar(t) {
samePoint(landed.world, start.world),
JSON.stringify({ jumped, landed }),
);
// Handed back as found: no transition for the steps after this one, and the zoom it had.
// A pan during the transition takes it along instead of jumping to its end.
const by = { x: 40, y: 30 };
const moved = { x: at.x + by.x, y: at.y + by.y };
await t.mouse('mouseMoved', at.x, at.y);
await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 });
await t.drag(at.x, at.y, moved.x, moved.y, { steps: 4, button: 'middle' });
const panned = await json('__gm.sceneView()');
await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended after the pan' });
const settled = await json('__gm.sceneView()');
const heldAfter = await json(`__gm.sceneWorldAt(${moved.x}, ${moved.y})`);
check(
'a pan during the transition carries it on: still on its way, then landed one step further, moved by the pan',
panned.moving === true &&
panned.zoom > landed.zoom &&
panned.zoom < settled.zoom &&
settled.zoom > landed.zoom &&
samePoint(heldAfter, start.world),
JSON.stringify({ landed, panned, settled, heldAfter, world: start.world }),
);
// Handed back as found: no transition for the steps after this one, and the view it had.
await t.evaluate('__gm.zoomTransition(0)');
await t.drag(moved.x, moved.y, at.x, at.y, { steps: 4, button: 'middle' });
await t.wheel(at.x, at.y, 100);
await t.wheel(at.x, at.y, 100);
}