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