Files
feuerwerk/src/editor/viewTween.ts
T
adminandClaude Opus 5.5 597367c804 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>
2026-10-11 23:08:43 +02:00

303 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
}