Co-Study4Grid / docs /performance /rendering-optimization-plan.md
github-actions[bot]
Deploy 7688ef1
13d4e44
|
Raw
History Blame Contribute Delete
18.8 kB
# Rendering Optimizations for Large Grid NAD Visualization
## Overview
Co-Study4Grid renders pypowsybl Network Area Diagrams (NAD) for power grids with 11,000+ lines and 500+ voltage levels. At this scale, naive rendering causes multi-second tab switches, invisible line colors at zoom-out, and zoom lag/crashes. This document traces the critical rendering features, their rationale, and regression risks.
## Critical CSS Properties
### 1. `vector-effect: non-scaling-stroke` — Line Visibility & Zoom Performance
**Files:** `frontend/src/App.css`, `standalone_interface.html` (CSS section)
```css
.svg-container svg path,
.svg-container svg line,
.svg-container svg polyline,
.svg-container svg rect {
vector-effect: non-scaling-stroke;
}
```
**What it does:** Keeps stroke widths at a constant screen-pixel size regardless of SVG viewBox zoom level.
**Why it's critical:**
- **Without it at full zoom-out:** Lines become sub-pixel width on large grids — native pypowsybl colors are invisible (the diagram appears as scattered dots)
- **Without it when zoomed in:** Strokes scale to hundreds of screen pixels, causing extremely expensive anti-aliased rendering → zoom lag and browser crashes
- **With it:** Strokes stay at ~1-2px screen width at any zoom level. Native pypowsybl line colors are always visible. Rendering cost is constant regardless of zoom.
**Regression history:** Removed in commit `6d03b24` ("Fix thick lines"), causing lines to lose visible colors and zoom to lag/crash. Restored in `df20d54`.
> **DO NOT REMOVE this CSS rule.** It is the single most impactful rendering property for large grids. pypowsybl SVGs include native colors on paths — this rule ensures they remain visible. If lines appear "too thick" at some zoom level, address it by adjusting individual stroke-width values, not by removing non-scaling-stroke.
### 2. `contain: layout style paint` — CSS Containment
**Files:** `frontend/src/App.css`, `standalone_interface.html`
```css
.svg-container {
contain: layout style paint;
}
```
**What it does:** Tells the browser that layout/paint within `.svg-container` is independent of the rest of the page.
**Why it's critical:** During viewBox changes (zoom/pan) and tab switches, the browser would otherwise propagate style/layout recalculations to ancestor elements. Containment limits the scope of recalculation to the SVG subtree.
### 3. `text-hidden` Class — Text Culling on Large Grids
**Files:** `frontend/src/App.css`, `standalone_interface.html`
```css
.svg-container.text-hidden foreignObject,
.svg-container.text-hidden .nad-edge-infos,
.svg-container.text-hidden .nad-text-edges {
display: none !important;
}
```
**What it does:** Hides thousands of text labels (foreignObject, edge info) when zoomed out on large grids. Text is too small to read at full zoom-out, and rendering it is expensive.
**When it activates:** Controlled by `usePanZoom` hook — text is hidden when the viewBox covers ≥55% of the original diagram size, shown when zoomed in to ≤45% (hysteresis prevents flicker near the boundary).
## Pan/Zoom Architecture (`usePanZoom`)
**Files:** `frontend/src/hooks/usePanZoom.ts`, `standalone_interface.html` (usePanZoom function)
### Design Principles
1. **Direct DOM manipulation during interaction** — viewBox changes go directly to the SVG element via `setAttribute`, bypassing React's render cycle entirely. React state is only updated when interaction ends (debounced).
2. **Cached SVG element reference**`svgElRef.current` is set once when the diagram loads (`useLayoutEffect([initialViewBox])`), avoiding repeated `querySelector('svg')` calls during the hot path (wheel/drag events).
3. **Debounced React state sync**`commitViewBox()` fires 150ms after the last wheel event, preventing React re-renders during rapid zoom.
4. **rAF-throttled drag** — Mouse move events are batched to at most one DOM update per display frame via `requestAnimationFrame`.
5. **Interaction-time paint culling** — while `.svg-interacting` is set (the gesture window), `App.css` drops the two most expensive paint element classes from the render tree: the HTML `<foreignObject>` voltage-level labels and the `.nad-edge-infos` flow values/arrows. They repaint once on settle. ~1.5–1.7x cheaper frames on the 5247-VL European grid, GPU-independent — this is the default. A CSS-transform GPU-compositing path (much smoother on GPU, but a regression under software/VDI rendering where every pan frame re-rasters new layer tiles) ships as the **off-by-default** "Smooth pan/zoom (GPU)" toggle (`utils/smoothPanZoom.ts`, read by `usePanZoom` at gesture start). Full write-up + benchmark: [`history/interaction-paint-culling.md`](history/interaction-paint-culling.md).
### Critical `useLayoutEffect` Hooks
```
┌─ useLayoutEffect([initialViewBox])
│ Cache svgElRef, apply text-hidden on large grids.
│ MUST have [initialViewBox] deps — without deps it runs every render,
│ blocking paint on every tab switch.
├─ useLayoutEffect([active])
│ When tab becomes active, apply current viewBox to SVG DOM BEFORE paint.
│ Prevents one frame of stale/default viewBox on tab switch.
└─ useLayoutEffect([activeTab]) — in App.tsx / standalone
Tab synchronization: copies viewBox from previous tab to new tab
before the browser paints, so the new tab shows the same zoom region.
```
> **Regression risk:** Changing any of these to `useEffect` will cause visible flicker on tab switch (one frame of wrong zoom state). Removing the `[initialViewBox]` dependency will cause all three `usePanZoom` instances to run `querySelector` on every React render, blocking paint for ~100-300ms on large grids.
## Tab-Switch Optimization
### Problem
On a France-scale grid (11,225 lines, ~500+ voltage levels), switching between N / N-1 / Action tabs was taking 1-3 seconds. The tab wouldn't appear until all decorations (highlights, voltage filter, delta visuals) finished running.
### Solution: Deferred Decorations
Highlights and voltage filters are deferred to the next animation frame on tab switch:
```
User clicks tab → React render → useLayoutEffect (viewBox sync)
→ Browser paints tab (SVG visible immediately)
→ requestAnimationFrame → apply highlights + voltage filter
```
**Implementation:**
- The highlight effect detects tab switches via `prevActiveTabRef`
- On tab switch: decorations are deferred via `requestAnimationFrame`
- On data change (same tab): decorations apply synchronously
**Stale tracking:** Inactive tabs are marked as "stale" in a `Set`. When switching to a stale tab, decorations re-apply in the deferred rAF callback.
### SVG Container Strategy
All three diagram containers (N, N-1, Action) stay mounted in the DOM with `visibility: hidden` / `z-index: -1` when inactive. This avoids destroying and recreating the SVG on every tab switch, preserving zoom state and avoiding expensive initial parse/render.
```jsx
<div style={{
zIndex: activeTab === 'n' ? 10 : -1,
visibility: activeTab === 'n' ? 'visible' : 'hidden',
}}>
```
## Highlight & Decoration Optimizations
### ID Map Cache (`getIdMap`)
**Files:** `frontend/src/utils/svgUtils.ts`, `standalone_interface.html`
Instead of `container.querySelector(`[id="${svgId}"]`)` (O(n) per call), a `Map<string, Element>` is built once per SVG and cached. Subsequent lookups are O(1). The cache is invalidated when the diagram changes.
### CTM Cache for Highlight Positioning
`getScreenCTM()` is cached per highlight pass instead of computed inside loops. The background layer's CTM is constant for all highlights in a single call, so caching it avoids redundant layout-forcing calls.
### Delta Visuals Guard
The `data-deltas-applied` attribute on the container tracks whether delta CSS classes have been applied. On cleanup, the expensive `querySelectorAll` scans only run when deltas were previously applied, skipping 4 full-tree scans when switching between Flows/Impacts mode and no deltas exist.
### Voltage Filter Early-Return
```javascript
if (minKv <= uniqueVoltages[0] && maxKv >= uniqueVoltages[uniqueVoltages.length - 1]) return;
```
When the voltage range slider covers all voltages (the default state), the filter skips iterating all nodes/edges — avoiding ~33,000 `style.display` writes on large grids.
## SVG Boost for Large Grids (`boostSvgForLargeGrid`)
**Files:** `frontend/src/utils/svgUtils.ts`, `standalone_interface.html`
For grids with ≥500 voltage levels and viewBox ratio > 3× the reference size (1250), text sizes, bus node radii, and edge info elements are scaled up proportionally so they're readable when zoomed in. The function:
1. Parses the SVG string with DOMParser
2. Scales font sizes, circle radii, and transform groups
3. Adds `data-large-grid` attribute (used by text-hidden CSS)
4. Serializes back to string
**Boost cache:** Results are cached in an LRU map (max 6 entries: N + N-1 + Action × 2 view modes) keyed by `length:vlCount:first200chars` to avoid redundant DOM parse/serialize on the same SVG.
## SVG DOM Recycling (`svgPatch`)
**Files:** `frontend/src/utils/svgPatch.ts`, `frontend/src/hooks/useDiagrams.ts`, `frontend/src/App.tsx`, `expert_backend/services/diagram_mixin.py`, `expert_backend/main.py`
### Problem
Before this work, switching to the N-1 tab or selecting a different action re-fetched the FULL pypowsybl NAD SVG every time. On the `bare_env_20240828T0100Z` reference grid (~10 k branches, ~12 MB SVG) this was:
- ~2–4 s of backend `get_network_area_diagram` work per click (the dominant cost),
- ~27 MB payload on the wire,
- ~250 ms of client-side `JSON.parse` + `DOMParser.parseFromString`,
- full re-layout of ~200 k DOM nodes.
The network topology is **byte-identical** across N, N-1, and most action variants when pypowsybl runs with `fixed_positions` — only a handful of elements actually change.
### Solution
Two new SVG-less endpoints ship only the per-branch delta needed to transform the N-state SVG DOM into N-1 / post-action. The frontend clones the already-mounted N-state `SVGSVGElement` and patches the clone in-place.
| Endpoint | When | Payload |
|---|---|---|
| `POST /api/contingency-diagram-patch` | N-1 tab fetch | `{disconnected_edges, absolute_flows, lines_overloaded, flow_deltas, asset_deltas, lf_*, ...}` — no SVG body |
| `POST /api/action-variant-diagram-patch` | Action click | Same shape, plus `vl_subtrees` (per-VL node subtree + affected edges) when bus counts change |
Client-side pipeline in `applyPatchToClone`:
1. **Splice per-VL subtrees** — for node-merging / splitting / coupling actions, the backend ships pypowsybl-native `<g id="nad-vl-*">` fragments (focused NAD at `depth=1`, rendered against the same `fixed_positions`). The client parses each fragment, rewrites the root `id` attribute to the main-diagram svgId (pypowsybl svgIds are positional — `nad-vl-0` in a focused sub vs. `nad-vl-42` in the main NAD), and splices via `replaceWith`. Same treatment for the affected branches' edge subtrees so their piercing geometry matches the new bus count.
2. **Mark disconnected edges dashed** — every branch whose `connected1 AND connected2` is false on the action variant gets the `.nad-disconnected` class (new CSS rule with `stroke-dasharray` + `vector-effect: non-scaling-stroke`). Covers the N-1 contingency plus any `disco_*` target; `reco_*` drops the class.
3. **Overwrite absolute flow labels** — backend ships `absolute_flows.p1/p2/q1/q2`, client rewrites each `edgeInfo1/2` text with the target-state value (backup in `data-patched-flow` distinct from the `data-original-text` owned by `applyDeltaVisuals`).
### Critical performance rule — id map
The flow-label loop touches ~2 × N edges (N ≈ 11 k on the reference grid). `clonedSvg.querySelector('[id=...]')` inside that loop is O(n_dom_nodes) per call ⇒ billions of comparisons ⇒ the browser tab locks up. Build the id map **once** with a single `querySelectorAll('[id]')`, then do O(1) `Map.get` lookups:
```ts
const idMap = buildSvgIdMap(clonedSvg); // one O(D) scan
for (const edgeId in absolute_flows.p1) {
const el = idMap.get(baseMetaIndex.edgesByEquipmentId.get(edgeId)?.edgeInfo1?.svgId);
if (el) patchEdgeInfoText(el, formatFlowValue(...));
}
```
**Do not** call `querySelector` in the flow-label / edge-splice / disconnected-edges loops. Same O(E·D) browser-lock trap that earlier highlight passes had (and why `getIdMap` exists).
### Fresh viewBox identity per patch
`usePanZoom` caches the live `<svg>` element via `svgElRef` and only refreshes that ref on `useLayoutEffect([initialViewBox])`. If the patch path passes the **same** `originalViewBox` object reference across N → patched-N-1 transitions, the layout-effect never re-runs and `svgElRef` keeps pointing at the previous (now-detached) clone. Pan/zoom then writes `viewBox` on a detached element — no visible change, main thread saturates — "page not responding".
**Fix:** shallow-copy `originalViewBox` on every patch so each transition produces a fresh object reference. See `App.tsx` fetchN1 and `useDiagrams.ts` handleActionSelect.
### Blank-flash elimination + stale-response guard
Two large-grid-only hazards:
- **Blank flash.** Calling `setActionDiagram(null)` synchronously on action click, followed by `await api.getActionVariantDiagramPatch(...)`, broke React's automatic batching. The null commit fired on its own → `innerHTML = ''` → container blank for the ~200–500 ms the patch needed. **Fix:** keep the previous cloned DOM mounted through the patch window; only null the diagram on explicit deselect (`actionId === null`).
- **Stale patch response.** Rapid A → B clicks with A's patch still in flight used to let A's late response `setActionDiagram(A)` after B's had already rendered, reverting the user's selection. **Fix:** `latestActionSelectRef` tracks the latest click; every await rechecks it on resume and drops a mismatch silently. Same guard around the full-NAD fallback.
### Fallback matrix
| Situation | Path |
|---|---|
| Normal N-1 selection with N diagram loaded | **patch** (`/api/contingency-diagram-patch`) |
| N-1 selection during session reload | full (`/api/contingency-diagram`) — preserves save/load contract |
| N-1 selection before N SVG is mounted | full fallback |
| PST / redispatch / load-shedding / curtailment | **patch** |
| Line disconnect / reconnect (`disco_*` / `reco_*`) | **patch** (toggles the `nad-disconnected` class) |
| Node merging / splitting / coupling | **patch with VL-subtree splice** (pypowsybl-native focused NAD per affected VL) |
| VL-subtree extraction partial / raises | full fallback (`patchable: false, reason: "vl_topology_changed"`) |
| Patch endpoint throws | full fallback |
### Combined-action line targets
`getActionTargetLines` used to evaluate `isCouplingAction` on the **full** combined action ID/description. For `disco_X+coupling_Y` the presence of "coupling" in the string suppressed every line-target extraction — the disco line lost its pink halo AND its clickable action-card badge. Fixed by splitting on `+` and evaluating the coupling flag **per sub-part**; topology-based bus/line extraction also limited to non-combined actions (combined topologies merge bus changes from multiple sub-actions and can't be cleanly attributed).
### Measured savings
On `bare_env_20240828T0100Z`, contingency `ARGIAL71CANTE`, warm-median of 3:
| Endpoint | Cold | Warm | Payload |
|---|---|---|---|
| `/api/contingency-diagram` (full) | 3.01 s | 2.39 s | 27.1 MB |
| `/api/contingency-diagram-patch` (new) | 0.49 s | 0.50 s | 5.5 MB |
| **Δ** | **−83.8 %** | **−79.1 %** | **20.3 % of full** |
Raw numbers in `profiling_patch_results.json`, benchmark driver in `benchmarks/bench_n1_diagram_patch.py`. Historical detail in `docs/performance/history/svg-dom-recycling.md`.
## Regression Test Coverage
**File:** `frontend/src/utils/cssRegression.test.ts`
Automated tests verify that critical CSS rules are present in both `App.css` and `standalone_interface.html`:
| Test Category | What It Verifies |
|---|---|
| `non-scaling-stroke` | CSS rule present for path/line/polyline/rect |
| CSS containment | `contain: layout style paint` on `.svg-container` |
| `text-hidden` | `display: none` for foreignObject when class active |
| Highlight styles | `.nad-overloaded` (orange), `.nad-action-target` (yellow) |
| Delta visualization | Positive (orange) and negative (blue) delta styles |
| `usePanZoom` guards | `useLayoutEffect` deps correct, tab sync uses `useLayoutEffect` |
| Voltage filter | Early-return when range covers all voltages |
| Deferred highlights | `requestAnimationFrame` used on tab switch |
| Boost cache | `_boostCache` and `BOOST_CACHE_MAX` present |
**File:** `frontend/src/hooks/usePanZoom.test.tsx`
Tests verify:
- ViewBox sync on mount, activation, and diagram changes
- ViewBox preservation across active/inactive transitions
- Text visibility toggle on large grids (hidden at zoom-out, visible at zoom-in)
- No corruption after rapid tab switching
## Summary: Do's and Don'ts
| Do | Don't |
|---|---|
| Use `vector-effect: non-scaling-stroke` on SVG elements | Remove it to fix "thick lines" — adjust stroke-width instead |
| Use `useLayoutEffect` for viewBox sync | Change to `useEffect` — causes visible flicker |
| Defer decorations via `requestAnimationFrame` on tab switch | Apply highlights synchronously on tab switch |
| Cache `getScreenCTM()` and ID maps | Call `querySelector` or `getScreenCTM()` in loops |
| Keep all SVG containers mounted (visibility toggle) | Conditionally render/destroy SVG containers on tab switch |
| Short-circuit voltage filter when range covers all | Iterate all elements even when no filtering needed |
| Run `cssRegression.test.ts` after CSS changes | Skip tests after modifying App.css or standalone CSS |
| Build an id map once per `applyPatchToClone` call | Call `querySelector('[id=...]')` inside flow-label / edge-splice loops |
| Shallow-copy `originalViewBox` on every patch so `usePanZoom` refreshes its cached `svgElRef` | Share the same viewBox object reference across N → patched-N-1 swaps |
| Rewrite spliced `<g id="nad-vl-*">` root ids to the main-diagram svgId | Trust the focused sub-diagram's positional svgIds as-is |
| Keep the previous cloned DOM mounted through the patch-fetch window | `setActionDiagram(null)` synchronously on click before an `await` |
| Drop late patch responses via `latestActionSelectRef` | Let a stale response overwrite the current action selection |
| Evaluate `isCouplingAction` per `+`-split part on combined actions | Evaluate it on the full combined action ID (suppresses line-target extraction) |