|
| 1 | +# Vue nodes on large graphs |
| 2 | + |
| 3 | +Vue node rendering was unusable on large workflows: every node in the graph |
| 4 | +was mounted as a DOM component tree at all times, at every zoom level. A |
| 5 | +3000-node workflow spent 310ms per frame while panning — roughly 3fps — where |
| 6 | +the canvas renderer managed 27ms on the same graph. |
| 7 | + |
| 8 | +This document records what was changed, what it bought, and what is left. |
| 9 | + |
| 10 | +## Result |
| 11 | + |
| 12 | +Median frame time while panning. "litegraph" is the canvas renderer as it was |
| 13 | +before any of this work, and is the bar Vue nodes needed to clear. |
| 14 | + |
| 15 | +| nodes | zoom | litegraph | vue before | vue after | vs litegraph | speedup | |
| 16 | +| ----: | ---- | --------: | ---------: | --------: | -----------: | ------: | |
| 17 | +| 1000 | 0.6 | 10.1 ms | 57.1 ms | 12.0 ms | 1.19x | 4.8x | |
| 18 | +| 1000 | min | 25.1 ms | 66.3 ms | 20.7 ms | 0.82x | 3.2x | |
| 19 | +| 1000 | fit | 37.2 ms | 81.4 ms | 34.2 ms | 0.92x | 2.4x | |
| 20 | +| 3000 | 0.6 | 27.3 ms | 310.5 ms | 23.1 ms | 0.85x | 13.4x | |
| 21 | +| 3000 | min | 41.6 ms | 280.5 ms | 34.3 ms | 0.82x | 8.2x | |
| 22 | +| 3000 | fit | 126.5 ms | 380.7 ms | 104.0 ms | 0.82x | 3.7x | |
| 23 | + |
| 24 | +Vue nodes now match or beat the original canvas-renderer baseline in five of |
| 25 | +six configurations. DOM element counts on the 3000-node graph fell from 227,787 |
| 26 | +to 5,355 at working zoom and to 418 (application chrome only) when zoomed out. |
| 27 | + |
| 28 | +`min` is the lowest zoom reachable by scrolling (0.1). `fit` is the zoom a |
| 29 | +workflow opens at, which the loader picks to frame the whole graph and which |
| 30 | +can be far below `min` — 0.0255 for the 3000-node graph. |
| 31 | + |
| 32 | +## What changed |
| 33 | + |
| 34 | +Each of these is a separate commit and can be reverted on its own. |
| 35 | + |
| 36 | +### Minimap change detection |
| 37 | + |
| 38 | +The minimap re-derived the entire graph every animation frame, and profiling a |
| 39 | +pan showed roughly 62% of samples inside it — more than the node DOM. Its data |
| 40 | +source ran a linear `find` over every node inside a loop over every node |
| 41 | +(O(n²), ~9M comparisons per frame at 3000 nodes), serialised every link to |
| 42 | +JSON to detect rewires, and rebuilt itself several times per render pass. |
| 43 | + |
| 44 | +Change detection now uses an allocation-free rolling digest, the data source is |
| 45 | +memoised per pass, node lookups go through the graph's own id index, and the |
| 46 | +loop polls at 100ms rather than every frame. Structural edits already force a |
| 47 | +redraw through event hooks; the loop only exists to catch drags and resizes, |
| 48 | +which emit no event. |
| 49 | + |
| 50 | +This is renderer-independent, so it made the canvas renderer faster too. |
| 51 | + |
| 52 | +### Viewport culling |
| 53 | + |
| 54 | +Only nodes intersecting an expanded viewport rect are mounted. Nodes mount as |
| 55 | +soon as they enter and unmount after a short delay, so nodes oscillating on the |
| 56 | +viewport edge do not thrash. Because that delay only elapses once panning |
| 57 | +stops, a long continuous pan would otherwise accumulate everything it swept |
| 58 | +over; an eager prune bounds the mounted set once it outgrows what is visible. |
| 59 | + |
| 60 | +Two constraints shaped this: |
| 61 | + |
| 62 | +- **The mounted set must not be a reactive dependency of the camera.** |
| 63 | + `TransformPane` deliberately writes its transform by direct DOM mutation to |
| 64 | + avoid re-diffing every node each frame; binding the node list to the camera |
| 65 | + would reintroduce exactly that cost. Camera state is sampled through a |
| 66 | + throttle instead. |
| 67 | +- **Culling must unmount, never `display: none`.** The shared `ResizeObserver` |
| 68 | + has no zero-size guard, so a hidden node writes 0x0 bounds back through |
| 69 | + `layoutStore` into `liteNode.size` and corrupts the saved workflow. |
| 70 | + |
| 71 | +Selected nodes are never culled, so unmounting cannot interrupt a drag or |
| 72 | +resize in progress. |
| 73 | + |
| 74 | +### Culling margin |
| 75 | + |
| 76 | +The margin was a fraction of the viewport, which is screen-space, so dividing |
| 77 | +it by the zoom made it cover more and more of the graph as the user zoomed out |
| 78 | +— at minimum zoom it more than doubled the queried area and mounted 2012 of |
| 79 | +3003 nodes. It is now capped in graph units, which leaves working zoom |
| 80 | +untouched and only binds below roughly 0.35 zoom. |
| 81 | + |
| 82 | +### Node culling index |
| 83 | + |
| 84 | +A QuadTree of node bounds, rebuilt only when `layoutStore.layoutVersion` |
| 85 | +changes, so panning over a static graph costs a tree query rather than a scan. |
| 86 | + |
| 87 | +It deliberately does not reuse the shared `SpatialIndexManager`: that index is |
| 88 | +built on `QUADTREE_CONFIG.DEFAULT_BOUNDS`, a fixed ±10000 box, and |
| 89 | +`QuadTree.insert` silently drops anything not fully contained — its return |
| 90 | +value is discarded, and `update()` cannot recover an item that was never |
| 91 | +inserted. Real workflows outgrow that box, so most nodes would be missing from |
| 92 | +query results. This index sizes its root to the graph's own extent. |
| 93 | + |
| 94 | +### Level of detail |
| 95 | + |
| 96 | +Below the zoom at which node text is legible, nodes are drawn as plain filled |
| 97 | +rectangles on a canvas and no Vue components are mounted at all. This mirrors |
| 98 | +what the canvas renderer has always done for its own low-quality pass, and uses |
| 99 | +the same threshold (`min_font_size_for_lod`, adjusted for DPR) so both |
| 100 | +renderers simplify at the same zoom. |
| 101 | + |
| 102 | +The drawing lives in `renderer/core/canvas/nodeBoxRenderer.ts` — a pure |
| 103 | +function over a 2D context and a set of bounds, with no graph or canvas |
| 104 | +renderer dependencies — alongside `CanvasPathRenderer`, which already owns link |
| 105 | +drawing. An earlier attempt drew these through the canvas renderer's own node |
| 106 | +pass; that was reverted, because it left the DOM renderer unable to draw a |
| 107 | +zoomed-out graph without litegraph present. |
| 108 | + |
| 109 | +Link rendering waits for nodes to report slot positions measured from the DOM. |
| 110 | +Simplified nodes have no slot elements, so on a graph large enough to open |
| 111 | +below the threshold that measurement never arrived and links stayed hidden. |
| 112 | +Crossing into the simplified mode now releases that wait, and links fall back |
| 113 | +to positions derived from node bounds. |
| 114 | + |
| 115 | +## Method |
| 116 | + |
| 117 | +Graphs are built by stamping the default workflow across empty space in a grid, |
| 118 | +which is how these workflows arise in practice. Each measurement loads a |
| 119 | +workflow, moves to a fixed zoom, and records frame deltas from a rAF loop |
| 120 | +driving a continuous pan, reporting the median and p95 of the stable portion. |
| 121 | +Both renderers are measured in the same build, on the same workflow, with a |
| 122 | +fresh page each. |
| 123 | + |
| 124 | +Scripts live under `temp/scripts/` (untracked): |
| 125 | + |
| 126 | +```bash |
| 127 | +node temp/scripts/gen-big-graph.mjs 3000 # build a workflow |
| 128 | +LABEL=my-run node temp/scripts/sweep.mjs # measure both renderers |
| 129 | +node temp/scripts/build-report.mjs # render temp/perf-report.html |
| 130 | +SCALE=0.0255 node temp/scripts/profile-pan.mjs temp/big-graph-3000.json |
| 131 | +``` |
| 132 | + |
| 133 | +Caveat: one sample per configuration, with run-to-run variance measured at |
| 134 | +roughly 10%. The large differences above are well clear of that; anything |
| 135 | +inside ~15% should be treated as a tie. |
| 136 | + |
| 137 | +## Known follow-ups |
| 138 | + |
| 139 | +- **`renderLink` rebuilds its render context per link, per frame**, re-walking |
| 140 | + the link-type colour map and reallocating the highlighted-id set. Roughly 15% |
| 141 | + of frame time at fit zoom, and invisible at working zoom because culling |
| 142 | + means few links draw. It is in the shared link path, so fixing it helps both |
| 143 | + renderers. The fix is hoisting the context out of the per-link loop rather |
| 144 | + than caching it, since `LGraphCanvas.link_type_colors` is a static dictionary |
| 145 | + that extensions mutate in place. |
| 146 | +- **The LOD threshold still reads the canvas renderer** for |
| 147 | + `min_font_size_for_lod` and `NODE_TEXT_SIZE`, and asks it to repaint when |
| 148 | + crossing. That keeps both renderers switching at the same zoom today; it can |
| 149 | + become a plain setting once the DOM renderer owns link drawing. The node |
| 150 | + boxes themselves have no such dependency. |
| 151 | +- **Links are still drawn by the canvas renderer.** `CanvasPathRenderer` owns |
| 152 | + the drawing, but `drawConnections` decides which links to draw and computes |
| 153 | + their endpoints. This predates this work and is the remaining obstacle to the |
| 154 | + DOM renderer standing alone. |
| 155 | +- **A fully zoomed-out large graph is slow in both renderers** — 104ms and |
| 156 | + 113ms respectively at 3000 nodes. Most of that is browser layout, paint and |
| 157 | + compositing for what is on screen, not JavaScript, so it is not obviously |
| 158 | + fixable by either renderer. |
0 commit comments