Files
feuerwerk/src/editor/graph/graphView.ts
T
adminandClaude Opus 5.5 597367c804 Keep a transition's pace through new steps and pans (#309)
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>
2026-10-11 23:08:43 +02:00

134 lines
5.0 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 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 });
}