Files
feuerwerk/src/editor/viewTween.ts
T
adminandClaude Opus 5.5 23922ee4a2
game-maker / gate (push) Successful in 3m58s
game-maker / suite (push) Failing after 14m51s
Show the zoom of the music tabs and the EQ as a transition (#377)
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>
2026-10-09 21:28:50 +02:00

185 lines
5.8 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, 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,
);
}