Spaces:
Sleeping
Pan/zoom fluidity — empirical evaluation of the "Smooth pan/zoom (GPU)" toggle
Grid: pypsa_eur_eur220_225_380_400 (5247 VL / 8205 branches; real pypowsybl NAD 9.1 MB, ~99k–104k DOM nodes). Hardware: Apple M4 Pro (Metal), Chrome 149, 120 Hz, dpr=1. Metric: median rAF frame interval during a sustained 2.5 s gesture (lower = smoother; 8.3 ms = 120 fps = idle ceiling). 100% "dropped" = never reaches the 120 Hz budget.
Harness: bench_fluidity.html driven over CDP (cdp_driver.mjs) in a Chrome launched
with anti-throttle flags so an occluded window still renders at full speed. The real-app
end-to-end numbers come from driving the actual usePanZoom handlers + the real Settings
toggle (app_toggle_driver.mjs, app_n1_driver.mjs).
Results
| Measurement | gesture | plain (no cull) | cull = toggle OFF | gpu transform = toggle ON | toggle gain |
|---|---|---|---|---|---|
| Synthetic NAD (isolated) | pan | 91.7 | 58.3 | 49.8 | 1.17× |
| Synthetic NAD (isolated) | zoom | 108.6 | 65.0 | 49.6 | 1.31× |
| Real pypowsybl NAD (isolated) | pan | 100.0 | 58.0 | 41.9 | 1.38× |
| Real pypowsybl NAD (isolated) | zoom | 126.7 (p95 1008!) | 65.1 | 43.3 | 1.50× |
| Real app, N tab (live usePanZoom) | pan | — | 60.1 | 50.0 | 1.20× |
| Real app, N tab (live usePanZoom) | zoom | — | 75.0 | 50.1 | 1.50× |
| Real app, N-1/Contingency tab | pan | — | 82.6 | 58.4 | 1.41× |
| Real app, N-1/Contingency tab | zoom | — | 83.9 | 66.7 | 1.26× |
(N-1/action share the identical render path — MemoizedSvgContainer + usePanZoom +
.svg-interacting; the action tab differs only by a few highlight elements, so its
fluidity equals N/N-1.)
Verdict on the toggle
- The toggle delivers a real but modest ~1.2–1.5× over the default. All four arms still drop 100% of frames — even ON, pan/zoom sit at ~15–24 fps, never near 60/120.
- Why it's small (proven): a pure ±2 px CSS translate of the
will-change'd<svg>layer is still ~42–48 ms/frame (micro_translate). Chrome re-rasterizes the ~100k-node vector SVG on every transform →will-change:transformnever yields a reusable GPU texture, so the gesture only saves the viewBox-attribute recompute + non-scaling-stroke re-eval.
What WOULD make it fluid (proven)
Rasterize the NAD to a <canvas> once at gesture start, transform the bitmap per frame,
bake back to viewBox on settle: 8.3 ms = 120 fps, 0 dropped frames — on both the
synthetic and the real NAD. That is ~6× over the toggle and ~7–8× over the default,
and it composites cheaply even on software/VDI (the case the GPU toggle deliberately skips).
The catch (verified in code, Phase C)
The bitmap is rasterized via new Image(), which renders the SVG in isolation → App.css
class-based styling is dropped. On the N-1 / action tabs that means overload halos,
the contingency glow, and flow-delta colors vanish (they come from
.nad-overloaded/.nad-contingency-highlight/.nad-action-target/.nad-delta-* rules,
not inline attributes — highlights.ts). The N tab is bare, which is exactly why the
8.3 ms held there. → A production bitmap mode must inline the highlight/delta computed
styles into the clone before serializing, and fix cursor-anchored wheel-zoom (the
getScreenCTM() source moves from the live SVG to the canvas).
Recommendations (ranked)
- (big bet) Bitmap-snapshot mode as a 3rd
usePanZoommode ('off' | 'gpu' | 'bitmap'), opt-in/default-OFF. Prereqs: inline halo/delta styles into the clone (fixes N-1/action fidelity), strip<foreignObject>(canvas taint — bench already does), dpr-scale the canvas, fix wheel-zoom CTM. Effort L–XL. Reference impl:bench_fluidity.htmlsnapshotCanvas/runBitmap. - (quick win — REJECTED after measurement) Drop
vector-effect:non-scaling-strokeduring.svg-interacting. Phase C measured ~1.15× on the synthetic NAD, but a direct A/B on the real pypowsybl NAD (__runNssarm, vector-effect verified flippingnone↔non-scaling-stroke) gave 1.00× — zero gain — on both GPU and software rendering (--disable-gpu): pan 50/50 ms, zoom 58.3/58.3 ms. The real bottleneck is pure SVG raster; the stroke recompute is negligible. Not shipped — ~60 lines of CSS + a halo/delta protection block for a measured no-op. The synthetic 1.15× did not transfer. [IMPLEMENTED then reverted.] - (SHIPPED)
commitViewBoxequality guard (usePanZoom.ts) — free, avoids a settle-frame React re-render when the viewBox nets back unchanged (the ~100k nodes are outside React's vdom viaReact.memo+replaceChildren, so it's a tiny App-level saving, not a fps mover). - (SHIPPED, hygiene) Clear
will-changepromptly on settle inendInteraction(smooth-mode only); does NOT shorten the 150 ms wheel debounce. Drops a promoted multi-MB compositor layer between gestures. 0 fps, pure hygiene. - Reject: geometry-count culling (≤1.15×, some regress;
content-visibility:autois 0.89×;.nad-edgeselectors don't even match the real grid) and a full canvas/WebGL rewrite (same 8.3 ms ceiling at XL effort + breaks svgPatch/inspect/SLD/highlights).