Show a wheel step or Fit as a short zoom transition (#309)

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 21:01:10 +02:00
1 parent 8452a99c64
commit cf6043a066
21 files changed
+779 -49

No files matched your search

+24 -8
View File
@@ -30,6 +30,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { findAsset, findGraphAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { ViewTween } from '../viewTween';
import { BeadClock } from './beads';
import {
activeDrag,
@@ -116,6 +117,14 @@ export class GraphCanvas extends Widget {
private assetId = '';
private pan = { x: 0, y: 0 };
private zoom = 1;
private readonly tween = new ViewTween({
read: () => ({ scale: this.zoom, x: this.pan.x, y: this.pan.y }),
write: (map) => {
this.pan = { x: map.x, y: map.y };
this.zoom = map.scale;
},
draw: () => this.applyView(),
});
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private snapping = false;
private readonly wiring: GraphWiring;
@@ -190,12 +199,15 @@ export class GraphCanvas extends Widget {
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('wheel', (e: FederatedWheelEvent) => {
({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
{ pan: this.pan, zoom: this.zoom },
this.toLocal(e.global),
zoomFactor(e.deltaY),
));
this.applyView();
const at = this.toLocal(e.global);
this.tween.change(() => {
({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
{ pan: this.pan, zoom: this.zoom },
at,
zoomFactor(e.deltaY),
));
this.applyView();
});
e.preventDefault();
});
@@ -226,11 +238,14 @@ export class GraphCanvas extends Widget {
}
fit(): void {
this.needsFit = true;
this.fitView();
this.tween.change(() => {
this.needsFit = true;
this.fitView();
});
}
saveView(): GraphViewState {
this.tween.land();
return { pan: { ...this.pan }, zoom: this.zoom, needsFit: this.needsFit };
}
@@ -507,6 +522,7 @@ export class GraphCanvas extends Widget {
// The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason.
const startPan = () => {
this.tween.finish();
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
};
if (e.button === 1) {
+25 -6
View File
@@ -1,6 +1,7 @@
import type { Container, FederatedPointerEvent, FederatedWheelEvent, Graphics } from 'pixi.js';
import { clamp, drawGrid, zoomFactor } from '../../ui';
import type { Point } from '../framing';
import { ViewTween } from '../viewTween';
/**
* What every surface that shows nodes shares with the graph editor: how far it zooms, how the grid
@@ -48,18 +49,34 @@ export function zoomedAbout(
export type WheelZoom =
{ min: number; max: number } | ((view: GraphView, at: Point, deltaY: number) => GraphView | null);
/** The transition (#309) of a board whose view is a `GraphView`. */
export function graphViewTween(
get: () => GraphView,
put: (view: GraphView) => void,
draw: () => void,
): ViewTween {
return new ViewTween({
read: () => ({ scale: get().zoom, x: get().pan.x, y: get().pan.y }),
write: (map) => put({ pan: { x: map.x, y: map.y }, zoom: map.scale }),
draw,
});
}
/**
* Drag with any button to pan, wheel to zoom about the pointer – all a board does with the pointer
* when its content takes none. `set` receives each new view and which gesture made it.
* when its content takes none. `set` receives each new view and which gesture made it; `tween`
* shows the wheel's views as a transition (#309).
*/
export function panAndZoom(
surface: Container,
get: () => GraphView,
set: (view: GraphView, gesture: 'pan' | 'zoom') => void,
zoom: WheelZoom = ZOOM_LIMITS,
tween: ViewTween,
): void {
let panning: { start: Point; pan: Point } | null = null;
surface.on('pointerdown', (e: FederatedPointerEvent) => {
tween.finish();
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
});
surface.on('globalpointermove', (e: FederatedPointerEvent) => {
@@ -72,11 +89,13 @@ export function panAndZoom(
surface.on('wheel', (e: FederatedWheelEvent) => {
e.preventDefault();
const at = surface.toLocal(e.global);
const view =
typeof zoom === 'function'
? zoom(get(), at, e.deltaY)
: zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom);
if (view) set(view, 'zoom');
tween.change(() => {
const view =
typeof zoom === 'function'
? zoom(get(), at, e.deltaY)
: zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom);
if (view) set(view, 'zoom');
});
});
}