Move a large graph instead of drawing it again (#292)
game-maker / gate (push) Successful in 2m44s
game-maker / suite (push) Successful in 13m55s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 10:18:20 +02:00
1 parent 32a0af6e2c
commit 6d79cf59a3
11 files changed
+393 -23

No files matched your search

+139
View File
@@ -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');
`);
}
}