A wheel step in the middle of a transition restarted the ease-out curve at full speed, so a run of quick steps kicked at each one. A pan called finish() first and made the zoom jump to its target. Both made the zoom and the gliding lists feel jerky. - A new step starts from the speed the view already has and then eases out. Quick steps in one direction move in one stroke. A step the other way still starts from rest. - A pan takes the transition along. The zoom goes on while dragging and lands where it was heading, moved by the pan. This covers all eight pan places: scene and container, graph, plugin board, texture and pixel art, timeline, pattern, song and sound. - finish() is gone, it has no callers left. Dragging the scrollbar, a jump to a row, another document or a new view size still end the transition as before. Found: on a second step the old curve jumped to 2.47 times the speed the view had just before. Tests: - npm run check on stein2: 299 files, 4598 tests (5 new) - e2e 46 on stein2: ALL PASSED, 24 checks (1 new). A pan by 40/30 px during a 1000 ms zoom step leaves it moving (2.33 on the way from 2 to 3), it lands on 3 with the world point moved by the pan. - Counter-checks: with the pace reset to 3 the new unit test fails with 2.47 instead of at most 1.1. Without carry the pan test fails. - melektaus-atx went offline mid-run, so both ran on stein2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
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
|
||
* is drawn and how the wheel zooms about the pointer. The graph editor and a plugin's node board
|
||
* look and move the same because they both come through here; the Tile tab's board borrows the
|
||
* panning and zooming.
|
||
*/
|
||
|
||
/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */
|
||
export const GRID_STEP = 24;
|
||
export const ZOOM_LIMITS = { min: 0.125, max: 2.5 };
|
||
/** Breathing room around the framed nodes, so they do not touch the edge. */
|
||
export const FIT_MARGIN = 24;
|
||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||
const MIN_GRID_STEP = 8;
|
||
|
||
export interface GraphView {
|
||
/** Where the graph origin sits on screen. */
|
||
pan: Point;
|
||
/** Screen pixels per graph unit. */
|
||
zoom: number;
|
||
}
|
||
|
||
/**
|
||
* The view after zooming by `factor` about a screen point, which keeps the graph point under it.
|
||
* `limits` are the graph's own unless a surface with other content says otherwise – the tile
|
||
* board shows 48 px tiles, which one wants to come closer to than a node graph.
|
||
*/
|
||
export function zoomedAbout(
|
||
view: GraphView,
|
||
at: Point,
|
||
factor: number,
|
||
limits: { min: number; max: number } = ZOOM_LIMITS,
|
||
): GraphView {
|
||
const zoom = clamp(view.zoom * factor, limits.min, limits.max);
|
||
const graphX = (at.x - view.pan.x) / view.zoom;
|
||
const graphY = (at.y - view.pan.y) / view.zoom;
|
||
return { pan: { x: at.x - graphX * zoom, y: at.y - graphY * zoom }, zoom };
|
||
}
|
||
|
||
/**
|
||
* How the wheel zooms: smoothly within limits, or by a rule of the surface's own – the Tile tab
|
||
* takes the scene view's steps. A rule returns null while the wheel has not made a step yet.
|
||
*/
|
||
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; `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.target(() => {
|
||
panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
|
||
});
|
||
});
|
||
surface.on('globalpointermove', (e: FederatedPointerEvent) => {
|
||
if (!panning) return;
|
||
const { start, pan } = panning;
|
||
tween.carry(() =>
|
||
set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan'),
|
||
);
|
||
});
|
||
surface.on('pointerup', () => (panning = null));
|
||
surface.on('pointerupoutside', () => (panning = null));
|
||
surface.on('wheel', (e: FederatedWheelEvent) => {
|
||
e.preventDefault();
|
||
const at = surface.toLocal(e.global);
|
||
tween.change(() => {
|
||
const view =
|
||
typeof zoom === 'function'
|
||
? zoom(get(), at, e.deltaY)
|
||
: zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom);
|
||
if (view) set(view, 'zoom');
|
||
});
|
||
});
|
||
}
|
||
|
||
/** What a node draws beyond its box: the shadow, the pins on its edges and the ring around a drop target. */
|
||
export const NODE_OVERHANG = 12;
|
||
|
||
/**
|
||
* Whether any of a box, with what hangs over its edges, is inside a view of `w` by `h` screen pixels.
|
||
* A graph is mostly looked at in parts, and a node outside the picture costs as much to draw as one
|
||
* inside it (#292).
|
||
*/
|
||
export function inView(
|
||
box: { x: number; y: number; w: number; h: number },
|
||
view: GraphView,
|
||
w: number,
|
||
h: number,
|
||
overhang = NODE_OVERHANG,
|
||
): boolean {
|
||
const left = (0 - view.pan.x) / view.zoom - overhang;
|
||
const top = (0 - view.pan.y) / view.zoom - overhang;
|
||
const right = (w - view.pan.x) / view.zoom + overhang;
|
||
const bottom = (h - view.pan.y) / view.zoom + overhang;
|
||
return box.x < right && box.x + box.w > left && box.y < bottom && box.y + box.h > top;
|
||
}
|
||
|
||
export function drawGraphGrid(g: Graphics, view: GraphView, w: number, h: number): void {
|
||
g.clear();
|
||
const step = GRID_STEP * view.zoom;
|
||
if (step < MIN_GRID_STEP) return;
|
||
// `pan` is where the graph origin sits on screen, but no axis lines: it marks nothing.
|
||
drawGrid(g, { originX: view.pan.x, originY: view.pan.y, step, w, h, alpha: 0.04 });
|
||
}
|