The zoom transition of #309 left out the Pattern, Song and Sound tabs
and the Parametric EQ's curve. There a wheel step or Fit still jumped,
whatever "Zoom (ms)" in the general settings said.
- axisTween wraps ViewTween for a single axis. The music tabs zoom
their time across and their rows, tracks or height down apart, so
each keeps one tween per axis and either goes its own way.
- Fit by its button is shown as a transition too, as in the other
tabs. The fit on opening a document still jumps.
- A middle-button pan first lands a transition under way, and saving
a document's view lands it on the step it was heading for.
- The EQ's range is saved on the node at once. While the zoom is on
its way the curve draws a range of its own and writes nothing.
Its dB lines are those of the new range, sliding to their place,
so no line shows a crooked number.
Found on the way, not part of #377:
- CI task 611 for 82e96e6 failed the stream check of e2e 34. The
renderer had grown by 60.1 MB with nothing decoded. The check
summed the RSS of every Chrome renderer, and the test Chrome runs
four of them. It now measures the largest one, the tab's own, and
writes all of them into the check's detail.
Tests:
- view-tween.test.ts: 18 tests, 2 new. They fail when axisTween
writes the wrong offset.
- e2e 34, 64, 65 on melektaus-atx: ALL PASSED, 202 checks, 4 new.
At 1000 ms each zoom is between its two steps a frame after the
wheel and lands on the step a jump reaches.
- Stream check: the tab's renderer grew by 8.6 MB streamed and by
66.9 MB decoded.
- Gate on melektaus-atx: CHECK EXIT 0, 280 files, 4321 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
5.8 KiB
TypeScript
185 lines
5.8 KiB
TypeScript
import { requestRender, 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 {
|
||
const rest = 1 - Math.min(1, Math.max(0, t));
|
||
return 1 - rest * rest * rest;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* 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.
|
||
*/
|
||
export class ViewTween {
|
||
private run: { from: ViewMap; to: ViewMap; start: number; ms: 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.
|
||
*/
|
||
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();
|
||
const 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;
|
||
}
|
||
if (!running) 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() };
|
||
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();
|
||
}
|
||
|
||
/** 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, easeOut(t)));
|
||
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;
|
||
}
|
||
|
||
/** 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,
|
||
);
|
||
}
|