The wheel and the Fit button used to jump to the new view. Now the view takes a short way there, 200 ms by default, everywhere the editor zooms. The zoom steps stay the same, only the change is shown. - viewTween: tweenView is the way between two views as a pure function. The scale moves by equal ratios, and the world point that sits at the same place on screen in both views stays there. After a wheel step that is the point under the pointer. - ViewTween drives it on the ticker. A surface hands it reading, writing and drawing its own view and wraps its wheel and Fit code in change(). A step during a transition starts from where that one was heading, so quick steps add up to exactly as many steps. - Anything else that moves the view ends the transition. A pan jumps to the target first, and saving a view saves the target. - Wired into FreeViewport (Scene, Container, animation preview), SheetCanvas (Texture, Curve, Pixel art, Vector art), GraphCanvas, panAndZoom (Tile board, plugin node board) and the timeline. - Menu > General settings... sets the time in ms, 0 jumps at once. It is kept in this browser like the panel sizes, not in the project, and it is not an undo step. - A document that opens still comes up framed at once. The animation preview frames a new subject itself instead of calling fit(). - The pixel art readout names the step the view is heading for. Measured in the scene view at 200 ms on melektaus-atx, where every frame of the transition is a full redraw: - 500 sprites: redraw 6.7 ms, 16 frames, mean 18 ms - 1000 sprites: redraw 13.0 ms, 9 frames, mean 27 ms - 2000 sprites: redraw 32.2 ms, 5 frames, mean 58 ms Tests: - The harness sets 0 ms on every load, so the existing modules read the step and not the way. - e2e 46i samples one wheel step at 1000 ms a frame later: zoom 1.033 between 1 and 2, the point under the pointer kept at (134, -40). It lands on the view a 0 ms jump reaches. - e2e 46 and 49 expect the new menu entry. - Gate (atx): 234 files, 3563 tests, CHECK EXIT 0 (+15) - e2e 46,49 (atx): ALL PASSED, 43 checks (+3, sum 1735) - Three counter-checks fail view-tween.test.ts as expected. e2e 46i fails when the scene view does not carry its centre along. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
7.5 KiB
TypeScript
226 lines
7.5 KiB
TypeScript
/** The zoom transition (#309): the way between two views, the driver that shows it, and its setting. */
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
generalSettings,
|
|
keepGeneralSettings,
|
|
resetGeneralSettings,
|
|
zoomTransitionMs,
|
|
} from '../../src/editor/generalSettings';
|
|
import { zoomedAbout } from '../../src/editor/graph/graphView';
|
|
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
|
|
import { easeOut, tweenView, type ViewMap, ViewTween } from '../../src/editor/viewTween';
|
|
|
|
function fakeClock() {
|
|
let time = 0;
|
|
const ticks = new Set<() => void>();
|
|
return {
|
|
now: () => time,
|
|
add: (fn: () => void) => void ticks.add(fn),
|
|
remove: (fn: () => void) => void ticks.delete(fn),
|
|
advance(ms: number) {
|
|
time += ms;
|
|
for (const fn of [...ticks]) fn();
|
|
},
|
|
get attached() {
|
|
return ticks.size;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A surface with a view and nothing else; `draws` counts what it was asked to draw. */
|
|
function surface(start: ViewMap = { scale: 1, x: 0, y: 0 }) {
|
|
const s = { view: { ...start }, draws: 0 };
|
|
const target = {
|
|
read: () => ({ ...s.view }),
|
|
write: (map: ViewMap) => void (s.view = { ...map }),
|
|
draw: () => void (s.draws += 1),
|
|
};
|
|
return { s, target };
|
|
}
|
|
|
|
/** The world point shown at a screen point. */
|
|
const worldAt = (view: ViewMap, x: number, y: number) => ({
|
|
x: (x - view.x) / view.scale,
|
|
y: (y - view.y) / view.scale,
|
|
});
|
|
|
|
describe('the way between two views', () => {
|
|
it('starts on the first and ends on the second', () => {
|
|
const from = { scale: 1, x: 10, y: 20 };
|
|
const to = { scale: 3, x: -40, y: 5 };
|
|
expect(tweenView(from, to, 0)).toEqual(from);
|
|
expect(tweenView(from, to, 1)).toEqual(to);
|
|
});
|
|
|
|
it('keeps the graph point under the pointer under it all the way', () => {
|
|
const from = { pan: { x: 120, y: 80 }, zoom: 1 };
|
|
const to = zoomedAbout(from, { x: 300, y: 200 }, 2);
|
|
const a = { scale: from.zoom, x: from.pan.x, y: from.pan.y };
|
|
const b = { scale: to.zoom, x: to.pan.x, y: to.pan.y };
|
|
for (const t of [0.1, 0.5, 0.9]) {
|
|
const mid = worldAt(tweenView(a, b, t), 300, 200);
|
|
expect(mid.x).toBeCloseTo(180, 9);
|
|
expect(mid.y).toBeCloseTo(120, 9);
|
|
}
|
|
});
|
|
|
|
it('keeps the picture pixel under the pointer under it in the sheet tabs', () => {
|
|
const from = { panX: 40, panY: -12, zoom: 4 };
|
|
const to = zoomAbout(from, 0.5, 250, 90);
|
|
const mid = tweenView(
|
|
{ scale: from.zoom, x: from.panX, y: from.panY },
|
|
{ scale: to.zoom, x: to.panX, y: to.panY },
|
|
0.4,
|
|
);
|
|
const pixel = localToImage({ panX: mid.x, panY: mid.y, zoom: mid.scale }, 250, 90);
|
|
const before = localToImage(from, 250, 90);
|
|
expect(pixel.x).toBeCloseTo(before.x, 9);
|
|
expect(pixel.y).toBeCloseTo(before.y, 9);
|
|
});
|
|
|
|
it('moves the scale by equal ratios, so halfway from 1 to 4 is 2', () => {
|
|
expect(tweenView({ scale: 1, x: 0, y: 0 }, { scale: 4, x: 0, y: 0 }, 0.5).scale).toBeCloseTo(2, 12);
|
|
});
|
|
|
|
it('slides between two views of the same scale', () => {
|
|
expect(tweenView({ scale: 2, x: 0, y: 10 }, { scale: 2, x: 100, y: 30 }, 0.25)).toEqual({
|
|
scale: 2,
|
|
x: 25,
|
|
y: 15,
|
|
});
|
|
});
|
|
|
|
it('eases out: most of the way early, nothing past the end', () => {
|
|
expect(easeOut(0)).toBe(0);
|
|
expect(easeOut(1)).toBe(1);
|
|
expect(easeOut(2)).toBe(1);
|
|
expect(easeOut(0.5)).toBeCloseTo(0.875, 12);
|
|
});
|
|
});
|
|
|
|
describe('the transition of a view', () => {
|
|
const double = (s: { view: ViewMap }) => () => {
|
|
s.view = { scale: s.view.scale * 2, x: s.view.x * 2, y: s.view.y * 2 };
|
|
};
|
|
|
|
it('shows the way from what was on screen to the new view, and lets go at the end', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
expect(s.view).toEqual({ scale: 1, x: 0, y: 0 });
|
|
expect(tween.moving).toBe(true);
|
|
expect(tween.heading).toEqual({ scale: 2, x: 0, y: 0 });
|
|
clock.advance(100);
|
|
expect(s.view.scale).toBeGreaterThan(1);
|
|
expect(s.view.scale).toBeLessThan(2);
|
|
clock.advance(100);
|
|
expect(s.view).toEqual({ scale: 2, x: 0, y: 0 });
|
|
expect(tween.moving).toBe(false);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
it('adds quick steps up to exactly as many steps, starting the way from what is shown', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
const shown = { ...s.view };
|
|
tween.change(double(s));
|
|
expect(tween.heading?.scale).toBe(4);
|
|
expect(s.view).toEqual(shown);
|
|
clock.advance(200);
|
|
expect(s.view.scale).toBe(4);
|
|
});
|
|
|
|
it('keeps its pace when a wheel event makes no step', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
tween.change(() => {});
|
|
clock.advance(150);
|
|
expect(tween.moving).toBe(false);
|
|
expect(s.view.scale).toBe(2);
|
|
});
|
|
|
|
it('lets go when something else moves the view', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
s.view = { scale: 7, x: 1, y: 2 };
|
|
clock.advance(16);
|
|
expect(s.view).toEqual({ scale: 7, x: 1, y: 2 });
|
|
expect(tween.moving).toBe(false);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
it('jumps at once at 0 ms', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 0, clock);
|
|
tween.change(double(s));
|
|
expect(s.view.scale).toBe(2);
|
|
expect(tween.moving).toBe(false);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
it('lands on the target without drawing, and finishes by drawing it', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
const draws = s.draws;
|
|
tween.land();
|
|
expect(s.view.scale).toBe(2);
|
|
expect(s.draws).toBe(draws);
|
|
tween.change(double(s));
|
|
tween.finish();
|
|
expect(s.view.scale).toBe(4);
|
|
expect(s.draws).toBe(draws + 1);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('the general settings', () => {
|
|
function memoryStore() {
|
|
const items = new Map<string, string>();
|
|
return {
|
|
items,
|
|
getItem: (key: string) => items.get(key) ?? null,
|
|
setItem: (key: string, value: string) => void items.set(key, value),
|
|
};
|
|
}
|
|
beforeEach(() => resetGeneralSettings());
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
resetGeneralSettings();
|
|
});
|
|
|
|
it('starts at 200 ms where nothing was kept', () => {
|
|
vi.stubGlobal('window', { localStorage: memoryStore() });
|
|
expect(generalSettings().zoomTransitionMs).toBe(200);
|
|
});
|
|
|
|
it('keeps the transition in the browser and reads it back on a new page', () => {
|
|
const store = memoryStore();
|
|
vi.stubGlobal('window', { localStorage: store });
|
|
keepGeneralSettings({ zoomTransitionMs: 350 });
|
|
resetGeneralSettings();
|
|
expect(generalSettings().zoomTransitionMs).toBe(350);
|
|
expect(JSON.parse(store.items.get('game-maker.general-settings') ?? '')).toEqual({
|
|
zoomTransitionMs: 350,
|
|
});
|
|
});
|
|
|
|
it('holds the transition to whole ms from 0 to 1000', () => {
|
|
expect([zoomTransitionMs(5000), zoomTransitionMs(-3), zoomTransitionMs(12.6)]).toEqual([1000, 0, 13]);
|
|
expect(zoomTransitionMs('fast')).toBe(200);
|
|
});
|
|
});
|