Architecture, Development: a large graph is moved, not drawn again (#292)

admin committed 2026-10-02 10:18:25 +02:00
1 parent f59d7604bf
commit 4ff5495274
2 files changed
+53 -4

No files matched your search

+52 -3
@@ -260,7 +260,7 @@ src/editor/graph/ GraphEditor (header, breadcrumb), GraphCanvas and what it
`GraphCanvasHost`: wiring (making edges — a drag from a pin, the palette a drop into
empty space opens, a transition armed from a state's menu), graphDrags (nodes, groups and
comments moved or resized, each gesture one undo step), annotationLayer (the boxes behind
the edges); graphView (zoom limits, grid, panAndZoom — shared with NodeBoard and the Tile tab's TileCanvas), EdgeLayer (curves and fold marks), graphMenus,
the edges); graphView (zoom limits, grid, panAndZoom — shared with NodeBoard and the Tile tab's TileCanvas — and `inView`, which nodes are in the picture, #292), EdgeLayer (curves and fold marks), graphMenus,
NodeView, NodePalette + paletteEntries (which also ranks a search), folding (hidden links, collapsed nodes), Breadcrumb + trail (the path into nested behaviors), beads (the run's pulses and the clock that launches them),
AnnotationView + annotationDrag (groups and comments: what a drag carries, each thing moved once),
SnapToggle (the magnet every tab that snaps carries, the vector art tab included, #194)
@@ -356,8 +356,9 @@ gesture runs, its own copy of the rings is the truth and it never reads the mode
Every other drag goes through `editor/framePreview.ts` (#213): `FramePreview.request()` in the
move handler, `cancel()` on release, one instance per gesture owner. The model is still written on
every pointer move, only `state.preview()` waits for the frame. The Scene view (moving objects,
polygon corners, tile strokes), the Texture tab (frames, slice edges, collision corners) and the
graph canvases (nodes, groups, comments) use it. Measured with 120 extra objects in the scene and
polygon corners, tile strokes) and the Texture tab (frames, slice edges, collision corners) use
it; the graph canvases did until #292 and now follow a drag without a project event at all, see
below. Measured with 120 extra objects in the scene and
120 pointer moves 8 ms apart: 26, 34 and 29 ms per move before, 18, 18 and 17 after (the probe's
own pause and round trip are in every number). The Texture tab had been paying twice: Pixi repeats the
last pointer move a few milliseconds later to update hover, with the same native event, and
@@ -371,6 +372,54 @@ stop, at 47 ms per move. Now it is 51 to 61 events and 22 ms per move. The Pixel
panel (the colour of a palette swatch, the opacity of a layer) and the Curve tab's `t` and `v`
fields follow the same rule; those were changed without a measurement of their own.
**A graph of many nodes is moved, not drawn again** (#292). Five rules keep a graph of a few
hundred nodes quick to pan, zoom and drag in, all in `editor/graph/`:
- *Panning and zooming move the picture.* `GraphCanvas.applyView` sets the position and scale of
the graph and draws anew only what is laid out on the screen: the grid and the rubber band. It
used to call `redraw()`, which rebuilt every box and every curve on every pointer move.
- *A node draws its box and makes its texts only when what they show has changed.* `NodeView`
keeps a signature of everything `redraw` reads and one of everything `rebuildTexts` reads, and
returns early while they hold. A model event still asks every node (`GraphCanvas.sync`), and a
run still calls `redraw()` every frame, but a text made anew is a picture uploaded anew, and that
was the cost: one node dragged remade around 1500 texts per frame in a graph of 388 nodes.
- *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`): the views are put where
their nodes are and the links drawn again. Where a node stands concerns no panel, no run and no
picture, so `state.preview()` is left out until the command of the release.
- *Nodes outside the picture are not drawn.* `inView` in `graphView.ts` is the rule, `cull()` sets
`visible` after every redraw and every change of the view. At a working zoom that is most of a
large graph.
- *The graph is a render group, and its shapes go into the batch.* `canvas.isRenderGroup` lets Pixi
move the whole graph as one and keeps a node entering the picture from rebuilding the drawing of
the whole editor. The mask sits on a container **above** the group: on the group itself it is
panned and zoomed along with the graph, which cut the picture down to a small window. Pixi draws
a `Graphics` of more than 200 vertices on its own, and the round pins of a node are that, so
box and pins set `context.batchMode = 'batch'`: 474 draw calls a frame became 160.
Measured in the CI image with a behavior of 97 nodes and 135 links taken from a real project, and
with four copies of it (388 nodes, 540 links), 240 pointer moves 16 ms apart, fitted to the canvas:
| | before | after |
|---|---|---|
| pan, 97 nodes: script per move | 6.7 ms | 1.9 ms |
| pan, 388 nodes: script per move | 25.2 ms | 2.0 ms |
| pan, 388 nodes: frames per second | 4.4 | 18.7 |
| drag a node, 97 nodes: frames per second | 4.4 | 33.4 |
| drag a node, 388 nodes: frames per second | 2.7 | 22.4 |
| the still picture, 388 nodes: frames per second | 9 | 21 |
Zoomed in to 1 the same gestures run at 59 frames a second with 97 nodes and at 29 to 38 with 388.
The frame rates are those of the test browser, whose GPU sits behind WSL's d3d12 driver and pays
for every draw call: with the graph hidden altogether the Behavior tab draws at 37 frames a second
there, because the panels around it cost 148 draw calls of their own, one pair per mask. The script
times are the part that carries over to another machine.
What a check can see of this: `__gm.graph.made()` counts how often texts, boxes and links were
really made, and `__gm.graph.nodesDrawn()` how many nodes are left in the picture. A probe that
wants the frame rate of a picture nobody changes has to ask for every frame itself (`__gm.wake()`
in a `requestAnimationFrame` loop): the editor draws on demand.
The Texture tab draws its frames in two layers (#220). `sheet` holds every frame but the selected
one with its outlines, `overlay` the selected frame, its handles and a frame being spanned. Both
are drawn without the pan, in a container that sits at the pan. A pan only moves that container,
+1 -1
@@ -18,7 +18,7 @@ php tests/php/accounts.test.php # the API's accoun
# has and the web server's container has not (SKIP there)
php tests/php/snapshots.test.php # the snapshot store (#231), the same way
php tests/php/releases.test.php # the release store (#266), the same way
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1656 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1660 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result