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 <noreply@anthropic.com>
This commit is contained in:
1 parent
32a0af6e2c
commit
6d79cf59a3
11 files changed
+393
-23
No files matched your search
@@ -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');
|
||||
`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user