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>
303 lines
9.7 KiB
TypeScript
303 lines
9.7 KiB
TypeScript
import { requestRender, type ScrollContainer, ui } from '../ui';
|
||
import { generalSettings } from './generalSettings';
|
||
|
||
/**
|
||
* A view as every zoomable surface of the editor has one, whatever its fields are called: a world
|
||
* point `p` is shown at `p * scale + (x, y)`.
|
||
*/
|
||
export interface ViewMap {
|
||
scale: number;
|
||
x: number;
|
||
y: number;
|
||
}
|
||
|
||
/** 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 - (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 };
|
||
}
|
||
|
||
/**
|
||
* The view `t` of the way from `from` to `to` (#309). The scale moves by equal ratios, so zooming
|
||
* in and out feel equally fast. The world point that sits at the same place on screen in both
|
||
* views stays there all the way – after a wheel step that is the point under the pointer. Two
|
||
* views of the same scale have no such point, and between them the view slides.
|
||
*/
|
||
export function tweenView(from: ViewMap, to: ViewMap, t: number): ViewMap {
|
||
if (t <= 0) return { ...from };
|
||
if (t >= 1) return { ...to };
|
||
const scale = from.scale * (to.scale / from.scale) ** t;
|
||
const gap = from.scale - to.scale;
|
||
if (Math.abs(gap) <= 1e-9 * from.scale)
|
||
return { scale, x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
|
||
const held = (a: number, b: number) => {
|
||
const world = (b - a) / gap;
|
||
return a + world * from.scale - world * scale;
|
||
};
|
||
return { scale, x: held(from.x, to.x), y: held(from.y, to.y) };
|
||
}
|
||
|
||
function near(a: ViewMap, b: ViewMap): boolean {
|
||
const close = (p: number, q: number) => Math.abs(p - q) <= 1e-6 * Math.max(1, Math.abs(p), Math.abs(q));
|
||
return close(a.scale, b.scale) && close(a.x, b.x) && close(a.y, b.y);
|
||
}
|
||
|
||
/** How a surface hands its view to the tween. */
|
||
export interface TweenedView {
|
||
read(): ViewMap;
|
||
/** Puts a view in place without drawing it. */
|
||
write(view: ViewMap): void;
|
||
draw(): void;
|
||
}
|
||
|
||
/** Time and frames; the editor's are the ticker's, a test brings its own. */
|
||
export interface TweenClock {
|
||
now(): number;
|
||
add(fn: () => void): void;
|
||
remove(fn: () => void): void;
|
||
}
|
||
|
||
const tickerClock: TweenClock = {
|
||
now: () => performance.now(),
|
||
add: (fn) => ui().app.ticker.add(fn),
|
||
remove: (fn) => ui().app.ticker.remove(fn),
|
||
};
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* 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;
|
||
speed: number;
|
||
left: ViewMap;
|
||
} | null = null;
|
||
private readonly tick = () => this.step();
|
||
|
||
constructor(
|
||
private readonly view: TweenedView,
|
||
private readonly duration: () => number = () => generalSettings().zoomTransitionMs,
|
||
private readonly clock: TweenClock = tickerClock,
|
||
) {}
|
||
|
||
/** Whether a transition is under way – the check scripts sample it. */
|
||
get moving(): boolean {
|
||
return this.run !== null;
|
||
}
|
||
|
||
/** Where the transition is heading, null while none is under way. */
|
||
get heading(): ViewMap | null {
|
||
return this.run ? { ...this.run.to } : null;
|
||
}
|
||
|
||
/**
|
||
* 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, as fast as the screen moved.
|
||
*/
|
||
change(change: () => void): void {
|
||
const shown = this.view.read();
|
||
const running = this.run;
|
||
if (running) this.view.write(running.to);
|
||
change();
|
||
const to = this.view.read();
|
||
let ms = this.duration();
|
||
if (ms <= 0 || near(shown, to)) {
|
||
this.stop();
|
||
if (running) this.view.draw();
|
||
return;
|
||
}
|
||
if (running && near(running.to, to)) {
|
||
this.view.write(shown);
|
||
return;
|
||
}
|
||
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: now, ms, speed, left: this.view.read() };
|
||
requestRender();
|
||
}
|
||
|
||
/**
|
||
* 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. */
|
||
land(): void {
|
||
const run = this.run;
|
||
if (!run) return;
|
||
this.stop();
|
||
this.view.write(run.to);
|
||
}
|
||
|
||
private step(): void {
|
||
const run = this.run;
|
||
if (!run) return;
|
||
if (!near(this.view.read(), run.left)) {
|
||
this.stop();
|
||
return;
|
||
}
|
||
const t = (this.clock.now() - run.start) / run.ms;
|
||
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();
|
||
requestRender();
|
||
}
|
||
|
||
private stop(): void {
|
||
if (!this.run) return;
|
||
this.run = null;
|
||
this.clock.remove(this.tick);
|
||
}
|
||
}
|
||
|
||
/** One axis of a view whose axes zoom apart (#377): `scale` pixels per unit, unit 0 at `offset`. */
|
||
export interface AxisMap {
|
||
scale: number;
|
||
offset: number;
|
||
}
|
||
|
||
/**
|
||
* Shows a wheel step of a scrolled list as a transition, as long as a zoom's. Quick steps add up as
|
||
* they do there, and dragging the bar or a jump to a row ends the transition where it stands.
|
||
*/
|
||
export function scrollTween(scroll: ScrollContainer, duration?: () => number, clock?: TweenClock): ViewTween {
|
||
const tween = axisTween(
|
||
() => ({ scale: 1, offset: scroll.offset }),
|
||
({ offset }) => scroll.scrollTo(offset),
|
||
() => {},
|
||
duration,
|
||
clock,
|
||
);
|
||
scroll.wheelStep = (step) => tween.change(step);
|
||
return tween;
|
||
}
|
||
|
||
/** A tween along one axis; the music tabs keep one per axis, so either zooms on its own. */
|
||
export function axisTween(
|
||
read: () => AxisMap,
|
||
write: (axis: AxisMap) => void,
|
||
draw: () => void,
|
||
duration?: () => number,
|
||
clock?: TweenClock,
|
||
): ViewTween {
|
||
return new ViewTween(
|
||
{
|
||
read: () => {
|
||
const { scale, offset } = read();
|
||
return { scale, x: offset, y: 0 };
|
||
},
|
||
write: (view) => write({ scale: view.scale, offset: view.x }),
|
||
draw,
|
||
},
|
||
duration,
|
||
clock,
|
||
);
|
||
}
|