From 6d79cf59a30cf635f288591f4cce361ce8cfe864 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 2 Oct 2026 10:18:20 +0200 Subject: [PATCH] Move a large graph instead of drawing it again (#292) 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 --- README.md | 2 +- src/editor/debug/graphHooks.ts | 3 + src/editor/graph/EdgeLayer.ts | 3 + src/editor/graph/GraphCanvas.ts | 70 +++++++++++++-- src/editor/graph/NodeView.ts | 60 ++++++++++++- src/editor/graph/graphDrags.ts | 25 ++++-- src/editor/graph/graphHost.ts | 2 + src/editor/graph/graphView.ts | 22 +++++ tests/editor/frame-preview.test.ts | 24 +++-- tests/editor/graph-view.test.ts | 66 ++++++++++++++ tools/e2e/06-graph.mjs | 139 +++++++++++++++++++++++++++++ 11 files changed, 393 insertions(+), 23 deletions(-) create mode 100644 tests/editor/graph-view.test.ts diff --git a/README.md b/README.md index 16855c10..f36813c3 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1656 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1660 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 8fcb0059..4c1dcd04 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -58,6 +58,9 @@ export function graphHooks({ state, editor }: HookContext) { snapping: () => graphEditor.canvas.snapEnabled, snapButton: () => snapButton(graphEditor.snap), zoom: () => graphEditor.canvas.zoomLevel, + /** Nodes left in the picture, and how often texts, boxes and links were really made (#292). */ + nodesDrawn: () => graphEditor.canvas.nodesDrawn(), + made: () => graphEditor.canvas.partsMade(), crumbs: () => graphEditor.breadcrumb.crumbsDrawn(), }, stateMachine: { diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index c8a0611f..e29e7d90 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -82,6 +82,8 @@ export class EdgeLayer extends Container { /** Every link the last draw stroked, bottom first – what a click on a line is tested against. */ private paths: EdgePath[] = []; private picked: ReadonlySet = new Set(); + /** How often the links were drawn, for the checks: a pan must not add to it (#292). */ + drawCount = 0; constructor() { super(); @@ -96,6 +98,7 @@ export class EdgeLayer extends Container { beads: (edgeId: string) => readonly number[] = () => [], picked: ReadonlySet = new Set(), ): void { + this.drawCount += 1; this.paths = []; this.picked = picked; this.curves.clear(); diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 6fb59d5a..9377935e 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -34,6 +34,7 @@ import { endDrag, onDragChange, pressCount, + requestRender, type MenuItem, type Size, Theme, @@ -65,7 +66,7 @@ import { NodePalette, type PaletteAction } from './NodePalette'; import { splitEntries } from './splitMenu'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView'; -import { drawGraphGrid, FIT_MARGIN, ZOOM_LIMITS, zoomedAbout } from './graphView'; +import { drawGraphGrid, FIT_MARGIN, inView, ZOOM_LIMITS, zoomedAbout } from './graphView'; import { GraphWiring } from './wiring'; /** One running instance's answer for a machine: which state it holds, and whose instance it is. */ @@ -96,6 +97,7 @@ export class GraphCanvas extends Widget { private readonly bg = new Graphics(); private readonly grid = new Graphics(); private readonly clip = new Graphics(); + private readonly clipped = new Container(); private readonly canvas = new Container(); private readonly annotationLayer = new AnnotationLayer((view, e) => this.onAnnotationPointerDown(view, e)); private readonly blockLayer = new Graphics(); @@ -159,6 +161,7 @@ export class GraphCanvas extends Widget { entries: () => this.entries(), newNode: (entry, at) => this.newNode(entry, at), redraw: () => this.redraw(), + moved: () => this.moved(), }; this.wiring = new GraphWiring(host); this.drags = new GraphDrags(host); @@ -169,8 +172,13 @@ export class GraphCanvas extends Widget { this.nodeLayer, this.tempEdge, ); - this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip); - this.canvas.mask = this.clip; + this.clipped.addChild(this.canvas); + this.addChild(this.bg, this.grid, this.clipped, this.band, this.hint, this.clip); + this.clipped.mask = this.clip; + // A group of its own: panning and zooming move it as one, and a node that enters or leaves the + // picture rebuilds the drawing of the graph only, not of the whole editor. The mask sits one + // level above it: on the group itself it would be panned and zoomed along with the graph. + this.canvas.isRenderGroup = true; this.cursor = 'default'; this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e)); @@ -304,6 +312,18 @@ export class GraphCanvas extends Widget { this.redraw(); } + /** + * A drag moved nodes or boxes in the model. Nothing a node shows depends on where it stands, so + * the views are put there and the links drawn again – without asking every node what it is. + */ + private moved(): void { + const asset = this.asset(); + if (!asset) return; + this.annotationLayer.sync(asset.graph); + for (const node of asset.graph.nodes) this.views.get(node.id)?.position.set(node.x, node.y); + this.redraw(); + } + private onAnnotationPointerDown(view: AnnotationView, e: FederatedPointerEvent): void { if (e.button === 1 || !this.assetId) return; e.stopPropagation(); @@ -430,10 +450,41 @@ export class GraphCanvas extends Widget { }); } + /** + * Moves the picture, and draws anew only what is laid out on the screen rather than in the graph: + * the grid and the band. Nodes and links keep their shapes – a pan that rebuilt them on every + * pointer move cost more than a frame in a graph of a few hundred nodes (#292). + */ private applyView(): void { this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y)); this.canvas.scale.set(this.zoom); - this.redraw(); + this.syncGrid(); + this.cull(); + this.drawBand(); + requestRender(); + } + + /** Takes the nodes outside the picture out of the drawing – see `inView`. */ + private cull(): void { + const view = { pan: this.pan, zoom: this.zoom }; + for (const node of this.views.values()) node.visible = inView(node, view, this.w, this.h); + } + + /** How many nodes the last redraw left in the picture. */ + nodesDrawn(): number { + let drawn = 0; + for (const node of this.views.values()) if (node.visible) drawn += 1; + return drawn; + } + + /** How often the texts and boxes of the nodes were really made, and the links drawn – a pan must add nothing. */ + partsMade(): { texts: number; box: number; links: number } { + const made = { texts: 0, box: 0, links: this.edgeLayer.drawCount }; + for (const node of this.views.values()) { + made.texts += node.made.texts; + made.box += node.made.box; + } + return made; } private screenToGraph(sx: number, sy: number): { x: number; y: number } { @@ -626,7 +677,7 @@ export class GraphCanvas extends Widget { const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom; if (!this.marquee.active && d < DRAG_THRESHOLD) return; this.marquee.active = true; - this.redraw(); + this.drawBand(); } } @@ -654,7 +705,7 @@ export class GraphCanvas extends Widget { private setIncoming(incoming: GraphCanvas['incoming']): void { if (!incoming && !this.incoming) return; this.incoming = incoming; - this.redraw(); + this.drawBand(); } private dropIncoming(drop: NonNullable): void { @@ -852,6 +903,7 @@ export class GraphCanvas extends Widget { target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null; view.redraw(); } + this.cull(); this.drawnBlocks = blockFrames(asset.graph, (id) => { const view = this.views.get(id); @@ -869,8 +921,14 @@ export class GraphCanvas extends Widget { ); this.wiring.draw(this.tempEdge, asset.graph); + this.drawBand(); + } + /** The rubber band and the place a dragged behavior would land, both in screen space. */ + private drawBand(): void { + const c = Theme.colors; this.band.clear(); + if (!this.asset()) return; const m = this.marquee; if (m?.active) { drawBand( diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 8ae2af7a..1c7b9511 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -131,6 +131,15 @@ export class NodeView extends Widget { private readonly valueTexts = new Map(); /** Where an output's value may be drawn: right-aligned before its label, clear of the input text. */ private valueSlots = new Map(); + /** + * What the texts and the box were last made from. A graph is redrawn on every model event and on + * every frame of a run, and for nearly every node nothing has changed: a text made anew is a + * picture uploaded anew, and a box cleared is a shape built anew (#292). + */ + private textsOf = ''; + private drawnOf = ''; + /** How often the texts and the box were really made, for the checks. */ + readonly made = { texts: 0, box: 0 }; /** * Not fixed at construction: a nested-behavior node's ports come from another graph, so the @@ -145,6 +154,10 @@ export class NodeView extends Widget { super(); this.def = def; this.cursor = 'grab'; + // Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred + // nodes were a few hundred draw calls. In the batch they share one. + this.box.context.batchMode = 'batch'; + this.ports.context.batchMode = 'batch'; this.addChild(this.box, this.ports); this.sync(node, { nodes: [node], edges: [] }, null, def); } @@ -215,10 +228,51 @@ export class NodeView extends Widget { const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD; // Sized before the texts: output labels are placed against the right edge. this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height); - this.rebuildTexts(); + const texts = this.textSignature(); + if (texts !== this.textsOf) { + this.textsOf = texts; + this.made.texts += 1; + this.rebuildTexts(); + } this.position.set(node.x, node.y); } + /** Everything `rebuildTexts` reads. */ + private textSignature(): string { + const rows: unknown[] = []; + for (let i = 0; i < this.rowCount(); i++) { + const input = this.def.inputs[i]; + const output = this.def.outputs[i]; + rows.push( + input ? [this.inputText(input), rowStyle(input)] : null, + output ? [output.label, rowStyle(output), output.type !== 'exec' && !output.ghost, output.key] : null, + ); + } + return JSON.stringify([this.w, this.collapsed, this.def.label, this.paramLines, this.problem, rows]); + } + + /** Everything `redraw` reads. */ + private drawSignature(): string { + const port = (p: PortDef) => `${p.key}:${p.paintType ?? p.type}:${p.ghost ? 1 : 0}`; + return JSON.stringify([ + this.w, + this.h, + this.collapsed, + this.def.category, + this.def.transitionEdges === true, + this.isStart, + this.hot, + this.selected, + this.problem !== null, + this.stateMark, + this.linked, + this.targetPort, + this.headerHeight(), + this.def.inputs.map(port), + this.def.outputs.map(port), + ]); + } + private collapsedWidth(): number { const title = CanvasTextMetrics.measureText(this.def.label, textStyle('accent')).width; return collapsedNodeWidth(title, TITLE_X + COLLAPSED_TITLE_END, NODE_WIDTH); @@ -408,6 +462,10 @@ export class NodeView extends Widget { } override redraw(): void { + const drawn = this.drawSignature(); + if (drawn === this.drawnOf) return; + this.drawnOf = drawn; + this.made.box += 1; const c = Theme.colors; this.box.clear(); this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); diff --git a/src/editor/graph/graphDrags.ts b/src/editor/graph/graphDrags.ts index b502c339..1a1ccb48 100644 --- a/src/editor/graph/graphDrags.ts +++ b/src/editor/graph/graphDrags.ts @@ -1,6 +1,7 @@ import { annotationNoun, normalizeBox } from '../../core/commands/annotationCommands'; import { oneStep } from '../../core/commands/CompoundCommand'; import { findAnnotation, MIN_ANNOTATION_SIZE } from '../../core/graph'; +import { cancelFrameWork, onFrame } from '../../ui'; import { dragOffset, resizeSize } from '../framing'; import { boxOf, @@ -11,7 +12,6 @@ import { travellers, type Travellers, } from './annotationDrag'; -import { FramePreview } from '../framePreview'; import type { AnnotationView } from './AnnotationView'; import type { GraphCanvasHost } from './graphHost'; import { GRID_STEP } from './graphView'; @@ -20,6 +20,8 @@ import type { Point } from './transitions'; /** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */ export const DRAG_THRESHOLD = 3; +let instances = 0; + /** Nodes, groups and comments dragged about the canvas, each gesture one undo step. */ export class GraphDrags { /** Nodes being dragged, with the boxes a band caught alongside them – they travel together. */ @@ -37,10 +39,17 @@ export class GraphDrags { moved: boolean; } | null = null; - private readonly perFrame: FramePreview; + /** + * The gesture writes the model on every pointer move, the canvas follows once a frame (#213) – and + * only the canvas: where a node stands concerns no panel, no run and no picture, so the drag does + * not announce a changed project until the command of its release does (#292). + */ + private readonly frameKey = `graph-drag:${(instances += 1)}`; - constructor(private readonly host: GraphCanvasHost) { - this.perFrame = new FramePreview(host.state, 'graph-drag'); + constructor(private readonly host: GraphCanvasHost) {} + + private follow(): void { + onFrame(this.frameKey, () => this.host.moved()); } /** A press on a node: the selection travels with it. False when there is no graph to move in. */ @@ -100,7 +109,7 @@ export class GraphDrags { const step = this.host.snapping() ? GRID_STEP : 0; // Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed. placeTravellers(graph, drag.along, (start) => dragOffset(start, dx, dy, step)); - this.perFrame.request(); + this.follow(); } /** Moves or resizes the dragged box in the model, for the preview; committed on release. */ @@ -123,7 +132,7 @@ export class GraphDrags { const offset = dragOffset(drag.grabbed.start, dx, dy, step); placeTravellers(graph, drag.along, () => offset); } - this.perFrame.request(); + this.follow(); } /** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */ @@ -131,7 +140,7 @@ export class GraphDrags { const drag = this.box; if (!drag) return false; this.box = null; - this.perFrame.cancel(); + cancelFrameWork(this.frameKey); const graph = this.host.asset()?.graph; const assetId = this.host.assetId(); if (!drag.moved || !graph || !assetId) return true; @@ -146,7 +155,7 @@ export class GraphDrags { releaseNodes(): void { const drag = this.nodes; this.nodes = null; - this.perFrame.cancel(); + cancelFrameWork(this.frameKey); const graph = this.host.asset()?.graph; const assetId = this.host.assetId(); if (!drag?.moved || !graph || !assetId) return; diff --git a/src/editor/graph/graphHost.ts b/src/editor/graph/graphHost.ts index 9f982302..849175bc 100644 --- a/src/editor/graph/graphHost.ts +++ b/src/editor/graph/graphHost.ts @@ -28,4 +28,6 @@ export interface GraphCanvasHost { /** The command that adds a node from the palette at a graph point. */ newNode(entry: PaletteEntry, at: Point): AddNodeCommand; redraw(): void; + /** A drag moved nodes or boxes in the model: their views follow, and the links between them. */ + moved(): void; } diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts index eba202fa..baee29b4 100644 --- a/src/editor/graph/graphView.ts +++ b/src/editor/graph/graphView.ts @@ -80,6 +80,28 @@ export function panAndZoom( }); } +/** 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; diff --git a/tests/editor/frame-preview.test.ts b/tests/editor/frame-preview.test.ts index 827d9ef3..3ddc07ec 100644 --- a/tests/editor/frame-preview.test.ts +++ b/tests/editor/frame-preview.test.ts @@ -109,33 +109,43 @@ describe('dragging a node in the graph', () => { toGraph: (p: { x: number; y: number }) => ({ x: p.x, y: p.y }), zoom: () => 1, snapping: () => false, + moved: vi.fn(), } as unknown as GraphCanvasHost; - return { state, node: asset.graph.nodes[0]!, drags: new GraphDrags(host) }; + return { state, host, node: asset.graph.nodes[0]!, drags: new GraphDrags(host) }; } - it('moves the node with every pointer move and previews once a frame', () => { + it('moves the node with every pointer move, and the canvas follows once a frame', () => { const tick = frames(); - const { state, node, drags } = rig(); + const { host, node, drags } = rig(); drags.grabNodes({ x: 10, y: 10 }); for (let i = 1; i <= 5; i++) { drags.move({ x: 10 + i * 10, y: 10 }); expect(node.x).toBe(i * 10); } - expect(state.preview).not.toHaveBeenCalled(); + expect(host.moved).not.toHaveBeenCalled(); tick(); - expect(state.preview).toHaveBeenCalledTimes(1); + expect(host.moved).toHaveBeenCalledTimes(1); + }); + + it('does not announce a changed project while the drag runs (#292)', () => { + const tick = frames(); + const { state, drags } = rig(); + drags.grabNodes({ x: 10, y: 10 }); + drags.move({ x: 50, y: 30 }); + tick(); + expect(state.preview).not.toHaveBeenCalled(); }); it('leaves nothing for the frame after the release: the command has the last word', () => { const tick = frames(); - const { state, node, drags } = rig(); + const { state, host, node, drags } = rig(); drags.grabNodes({ x: 10, y: 10 }); drags.move({ x: 50, y: 30 }); drags.releaseNodes(); expect(state.execute).toHaveBeenCalledTimes(1); expect(node.x).toBe(0); tick(); - expect(state.preview).not.toHaveBeenCalled(); + expect(host.moved).not.toHaveBeenCalled(); }); }); diff --git a/tests/editor/graph-view.test.ts b/tests/editor/graph-view.test.ts new file mode 100644 index 00000000..e8527a5e --- /dev/null +++ b/tests/editor/graph-view.test.ts @@ -0,0 +1,66 @@ +/** + * A graph is mostly looked at in parts (#292): a node outside the picture is left out of the drawing, + * and what decides that is where its box, with what hangs over its edges, stands against the view. + */ +import { describe, expect, it } from 'vitest'; +import { inView, NODE_OVERHANG } from '../../src/editor/graph/graphView'; + +const box = (x: number, y: number) => ({ x, y, w: 176, h: 80 }); +const plain = { pan: { x: 0, y: 0 }, zoom: 1 }; + +describe('which nodes are in the picture', () => { + it('keeps a node inside the view and drops one far outside it, on every side', () => { + expect(inView(box(100, 100), plain, 800, 600)).toBe(true); + expect(inView(box(-400, 100), plain, 800, 600)).toBe(false); + expect(inView(box(1000, 100), plain, 800, 600)).toBe(false); + expect(inView(box(100, -300), plain, 800, 600)).toBe(false); + expect(inView(box(100, 800), plain, 800, 600)).toBe(false); + }); + + it('keeps a node that only reaches into the view with a corner', () => { + expect(inView(box(-170, -70), plain, 800, 600)).toBe(true); + expect(inView(box(790, 590), plain, 800, 600)).toBe(true); + }); + + it('keeps a node just outside for what it draws beyond its box', () => { + // The pins sit on the edges and the shadow falls below: a box that ends right at the border + // still has pixels inside it. + expect(inView(box(-176 - NODE_OVERHANG + 1, 100), plain, 800, 600)).toBe(true); + expect(inView(box(-176 - NODE_OVERHANG, 100), plain, 800, 600)).toBe(false); + expect(inView(box(800 + NODE_OVERHANG - 1, 100), plain, 800, 600)).toBe(true); + expect(inView(box(800 + NODE_OVERHANG, 100), plain, 800, 600)).toBe(false); + expect(inView(box(100, -80 - NODE_OVERHANG + 1), plain, 800, 600)).toBe(true); + expect(inView(box(100, -80 - NODE_OVERHANG), plain, 800, 600)).toBe(false); + expect(inView(box(100, 600 + NODE_OVERHANG - 1), plain, 800, 600)).toBe(true); + expect(inView(box(100, 600 + NODE_OVERHANG), plain, 800, 600)).toBe(false); + }); + + it('follows the pan', () => { + const far = box(2000, 1500); + expect(inView(far, plain, 800, 600)).toBe(false); + expect(inView(far, { pan: { x: -1800, y: -1300 }, zoom: 1 }, 800, 600)).toBe(true); + expect(inView(box(100, 100), { pan: { x: -1800, y: -1300 }, zoom: 1 }, 800, 600)).toBe(false); + // Each direction by itself: a node left behind on one axis is out, whatever the other says. + expect(inView(box(100, 100), { pan: { x: -1800, y: 0 }, zoom: 1 }, 800, 600)).toBe(false); + expect(inView(box(100, 100), { pan: { x: 0, y: -1300 }, zoom: 1 }, 800, 600)).toBe(false); + expect(inView(box(-500, -400), { pan: { x: 600, y: 500 }, zoom: 1 }, 800, 600)).toBe(true); + }); + + it('follows the zoom: zoomed out the view holds more, zoomed in less', () => { + const node = box(1500, 100); + expect(inView(node, plain, 800, 600)).toBe(false); + expect(inView(node, { pan: { x: 0, y: 0 }, zoom: 0.5 }, 800, 600)).toBe(true); + expect(inView(box(500, 100), { pan: { x: 0, y: 0 }, zoom: 2 }, 800, 600)).toBe(false); + // Panned and zoomed at once, the view is the graph from 500 to 900 across and 500 to 800 down. + const both = { pan: { x: -1000, y: -1000 }, zoom: 2 }; + expect(inView(box(600, 560), both, 800, 600)).toBe(true); + expect(inView(box(300, 560), both, 800, 600)).toBe(false); + expect(inView(box(950, 560), both, 800, 600)).toBe(false); + expect(inView(box(600, 380), both, 800, 600)).toBe(false); + expect(inView(box(600, 900), both, 800, 600)).toBe(false); + }); + + it('holds nothing while the canvas has no size', () => { + expect(inView(box(100, 100), plain, 0, 0)).toBe(false); + }); +}); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 68915de1..216f77d1 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1109,6 +1109,7 @@ export async function graph(t) { await animationPlaying(); await layerOf(); await pickedLinks(); + await largeGraph(); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); @@ -2025,4 +2026,142 @@ export async function graph(t) { p.assets = p.assets.filter((a) => a.id !== 'lky'); `); } + + // 6z-xii. A graph of many nodes stays quick to move in (#292): panning moves the picture without + // making a node anew, a dragged node takes its links along without one text being made, + // and zoomed in only the nodes in the picture are drawn at all. + async function largeGraph() { + await mutate(` + const nodes = []; + const edges = []; + for (let row = 0; row < 6; row++) + for (let col = 0; col < 8; col++) { + const id = 'big-' + row + '-' + col; + nodes.push({ id, type: col === 0 ? 'Number' : 'Add', x: col * 260, y: row * 160, params: col === 0 ? { value: 1 } : {} }); + if (col > 0) + edges.push({ id: 'e-' + id, from: { node: 'big-' + row + '-' + (col - 1), port: col === 1 ? 'value' : 'result' }, to: { node: id, port: 'a' } }); + } + __gm.state.project.assets.push({ + id: 'bigy', + type: 'behavior', + name: 'Big graph', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: { nodes, edges }, + }); + __gm.state.select({ kind: 'asset', assetId: 'bigy' }); + `); + await showTab('graph'); + await clickText('Fit'); + const made = () => json('__gm.graph.made()'); + const drawn = () => json('__gm.graph.nodesDrawn()'); + const rectOf = (id) => json(`__gm.graph.nodeRect(${JSON.stringify(id)})`); + const near = (a, b) => Math.abs(a - b) <= 1.5; + const area = await json('__gm.graph.canvasRect()'); + const centre = { x: area.x + area.w / 2, y: area.y + area.h / 2 }; + const allDrawn = await drawn(); + // The far corners of the graph are really on screen: what is drawn there is not the bare canvas. + const colourAt = (p) => evaluate(`__gm.pixelAt(${Math.round(p.x)}, ${Math.round(p.y)})`); + const bare = await colourAt({ x: area.x + 3, y: area.y + 3 }); + const corners = []; + for (const id of ['big-0-0', 'big-0-7', 'big-5-0', 'big-5-7']) + corners.push(await colourAt(await json(`__gm.graph.nodeAt(${JSON.stringify(id)})`))); + + const before = { made: await made(), rect: await rectOf('big-2-3') }; + await drag(centre.x, centre.y, centre.x + 80, centre.y + 50, { button: 'middle' }); + const panned = { made: await made(), rect: await rectOf('big-2-3') }; + check( + 'panning a large graph moves the picture and makes no box, no text and no link anew', + allDrawn === 48 && + corners.every((c) => c !== bare) && + near(panned.rect.x - before.rect.x, 80) && + near(panned.rect.y - before.rect.y, 50) && + panned.made.box === before.made.box && + panned.made.texts === before.made.texts && + // Twelve pointer moves, and the links are drawn once: by the release, as after any gesture. + panned.made.links - before.made.links <= 1, + JSON.stringify({ allDrawn, bare, corners, before, panned }), + ); + + // Mid-drag, before the release has run its command: the node and its link are where the pointer is. + const grip = await json(`__gm.graph.nodeAt('big-2-3')`); + const linkBefore = await json(`__gm.graph.edgeAt('e-big-2-3')`); + await t.mouse('mouseMoved', grip.x, grip.y); + await t.mouse('mousePressed', grip.x, grip.y); + for (let i = 1; i <= 10; i++) { + await t.mouse('mouseMoved', grip.x + i * 6, grip.y + i * 4, 'left', { buttons: 1 }); + await t.sleep(16); + } + await t.settle(); + const during = { + made: await made(), + rect: await rectOf('big-2-3'), + link: await json(`__gm.graph.edgeAt('e-big-2-3')`), + status: await evaluate('__gm.state.saveStatus'), + }; + await t.mouse('mouseReleased', grip.x + 60, grip.y + 40); + await t.settle(); + const dropped = { + made: await made(), + node: (await asset('bigy')).graph.nodes.find((n) => n.id === 'big-2-3'), + }; + await undo(); + const undone = (await asset('bigy')).graph.nodes.find((n) => n.id === 'big-2-3'); + await shot('31-large-graph'); + check( + 'a dragged node follows the pointer with its link, and no text is made anew – not for it, not on release', + near(during.rect.x - panned.rect.x, 60) && + near(during.rect.y - panned.rect.y, 40) && + during.link.x - linkBefore.x > 20 && + during.made.texts === panned.made.texts && + dropped.made.texts === panned.made.texts && + // Its border changes with the selection, and nothing else is drawn again. + dropped.made.box - panned.made.box <= 2 && + dropped.node.x !== 3 * 260 && + undone.x === 3 * 260 && + undone.y === 2 * 160, + JSON.stringify({ panned, during, linkBefore, dropped, undone }), + ); + + const corner = await json(`__gm.graph.nodeAt('big-0-0')`); + for (let i = 0; i < 14 && (await json('__gm.graph.zoom()')) < 1; i++) + await t.wheel(corner.x, corner.y, -120); + const zoomedIn = { zoom: await json('__gm.graph.zoom()'), drawn: await drawn(), made: await made() }; + await shot('32-large-graph-near'); + await clickText('Fit'); + const fitted = { drawn: await drawn(), made: await made() }; + const backAt = await colourAt(await json(`__gm.graph.nodeAt('big-5-7')`)); + check( + 'zoomed in, only the nodes in the picture are drawn; Fit brings all back without making one anew', + zoomedIn.zoom >= 1 && + zoomedIn.drawn > 0 && + zoomedIn.drawn < 20 && + fitted.drawn === 48 && + backAt !== bare && + fitted.made.texts === zoomedIn.made.texts && + fitted.made.box === zoomedIn.made.box && + fitted.made.links === zoomedIn.made.links, + JSON.stringify({ zoomedIn, fitted, backAt, bare }), + ); + + // The other side of the same rule: what a node shows does change with its value. + const textsBefore = (await made()).texts; + await mutate(` + __gm.state.project.assets.find((a) => a.id === 'bigy').graph.nodes.find((n) => n.id === 'big-0-0').params.value = 7; + `); + const shown = JSON.stringify([ + await json(`__gm.graph.nodeSettings('big-0-0')`), + await json(`__gm.graph.nodeInputs('big-0-0')`), + ]); + const textsAfter = (await made()).texts; + check( + 'a node whose value changed shows the new one, and is the only one whose texts are made anew', + shown.includes('7') && textsAfter - textsBefore === 1, + JSON.stringify({ shown, textsBefore, textsAfter }), + ); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'bigy'); + `); + } }