In a graph of many nodes the frame rate dropped while panning, and more so while one node was dragged. Every pointer move rebuilt every box, every text and every curve, and a drag also announced a changed project, so the panels rebuilt along with it. - Panning and zooming move the picture. GraphCanvas.applyView sets position and scale and draws anew only the grid and the rubber band. It used to call redraw(). - A node draws its box and makes its texts only when what they show has changed. NodeView keeps a signature of what redraw reads and one of what rebuildTexts reads, and returns early while they hold. - A drag does not announce a changed project. GraphDrags writes the model on every pointer move and asks the canvas once a frame to follow (GraphCanvasHost.moved). state.preview() waits for the command of the release. - Nodes outside the picture are not drawn. inView in graphView.ts is the rule, cull() applies it after every redraw and every change of the view. - The graph is a render group, and box and pins go into the batch. The mask sits on a container above the group, on the group itself it would be panned and zoomed along. - The rubber band and the drop mark of a dragged behavior are drawn on their own (drawBand), without a redraw of the graph. Measured in the CI image with 388 nodes and 540 links, fitted to the canvas, 240 pointer moves 16 ms apart: - Pan, script per move: 25.2 ms before, 2.0 ms after - Pan: 4.4 frames per second before, 18.7 after - Drag a node: 2.7 frames per second before, 22.4 after - The still picture: 9 frames per second before, 21 after Not solved: the still picture of such a graph stays at 21 frames per second in the test browser. What is left is the drawing itself, and the frame rates are those of that browser, not of a desktop GPU. Tests: - Gate on melektaus-atx: 207 files, 3121 tests (3114 + 7), exit 0 - e2e 4,6,18,19,20,23,24,26,28,31,36,43,51,53 on melektaus-atx: 542 pass, 1 fail. The fail is module 51 after module 43, an order the suite does not have, and it fails the same way without this change. Module 51 alone: 12 checks, all pass. - No full run and no smokes. The sum of 1660 (1656 + 4) is reckoned, and the CI workflow is switched off. - Seven unit tests: six for inView in tests/editor/graph-view.test.ts and one in frame-preview.test.ts - Four e2e checks in block 6z-xii of module 06: pan, drag, zoom in and fit, a changed value - Seven browser counter-probes fail 2 to 3 checks each Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.4 KiB
TypeScript
112 lines
4.4 KiB
TypeScript
import type { Container, FederatedPointerEvent, FederatedWheelEvent, Graphics } from 'pixi.js';
|
||
import { clamp, drawGrid, zoomFactor } from '../../ui';
|
||
import type { Point } from '../framing';
|
||
|
||
/**
|
||
* 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.25, 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);
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export function panAndZoom(
|
||
surface: Container,
|
||
get: () => GraphView,
|
||
set: (view: GraphView, gesture: 'pan' | 'zoom') => void,
|
||
zoom: WheelZoom = ZOOM_LIMITS,
|
||
): void {
|
||
let panning: { start: Point; pan: Point } | null = null;
|
||
surface.on('pointerdown', (e: FederatedPointerEvent) => {
|
||
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;
|
||
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);
|
||
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 });
|
||
}
|