Skip to content

Optimize pan/zoom performance and metadata lookups - #5

Merged
marota merged 10 commits into
claude/grid-zoom-scaling-UpSPyfrom
claude/optimize-svg-rendering-QLZxG
Feb 15, 2026
Merged

marota merged 10 commits into
claude/grid-zoom-scaling-UpSPyfrom
claude/optimize-svg-rendering-QLZxG

Conversation

@marota

@marota marota commented Feb 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR significantly improves the performance of the SVG diagram viewer by optimizing the pan/zoom interaction loop and metadata-based element lookups. The changes bypass React's render cycle during active interactions and replace O(n) array searches with O(1) Map-based lookups.

Key Changes

Pan/Zoom Performance Optimization

  • Direct DOM updates during interaction: Introduced viewBoxRef to update SVG viewBox directly via DOM without triggering React renders during active panning/zooming
  • React state sync on interaction end: ViewBox state is only synced to React after interactions complete (debounced wheel events, mouseup for drag)
  • RequestAnimationFrame throttling: Drag events are now throttled to one DOM update per display frame using rAF, preventing excessive reflows
  • Cached SVG element reference: Added svgElRef to avoid repeated querySelector calls on every event handler invocation
  • Debounced wheel scroll: Wheel zoom events now debounce the React state commit (150ms), keeping the hot path DOM-only

Metadata Indexing

  • Pre-computed Map indices: Added buildMetadataIndex() function that converts node/edge arrays into Maps indexed by equipmentId and svgId
  • O(1) lookups in zoomToElement: Replaced .find() calls (O(n)) with Map .get() calls (O(1)) for element lookups
  • Memoized indices: Metadata indices are memoized with useMemo to avoid rebuilding on every render

SVG Scaling Refactor

  • DOMParser-based manipulation: Replaced regex-based string replacements with DOM parsing for more robust and maintainable SVG scaling
  • Avoids large string copies: Using DOM APIs instead of regex on multi-MB SVG strings eliminates temporary string allocations

DOM Efficiency

  • Conditional mounting: Changed N and N-1 diagram containers from visibility: hidden to conditional rendering with &&, reducing DOM weight when inactive
  • Removed unnecessary dependencies: Event listener effect now only depends on active tab, not viewBox, preventing unnecessary re-registrations

Implementation Details

  • The usePanZoom hook now maintains two viewBox representations: viewBoxRef (mutable, hot path) and React state (settled, for downstream consumers)
  • Event handlers use activeRef to avoid stale closure issues while keeping the effect dependency array minimal
  • SVG element caching via useLayoutEffect ensures the ref is updated when diagram content changes
  • The public setViewBoxPublic API updates both the ref and React state immediately for programmatic changes (e.g., zoom-to-element)

https://claude.ai/code/session_01N3CzfYViwD4Xhs9aFnZafv

claude and others added 10 commits February 15, 2026 09:16
The pan/zoom was sluggish because every wheel/mouse event triggered a
full React render cycle just to update the SVG viewBox attribute. For
large SVGs injected via dangerouslySetInnerHTML, React had to examine
the entire component tree on each frame despite nothing changing.

Key optimizations:
- Bypass React state for viewBox during active pan/zoom: update the
  DOM attribute directly via refs, sync back to React state only on
  interaction end/pause (debounced wheel, mouseup)
- Stabilize event listener registration: depend only on [active] not
  [viewBox], eliminating 60+/sec listener teardown/setup cycles
- Cache SVG element ref to avoid querySelector on every event
- Throttle mouse drag via requestAnimationFrame (one DOM update per
  display frame)
- Conditional rendering for inactive SVG tabs: unmount the hidden tab's
  SVG DOM tree, halving memory and layout overhead
- Replace regex-based SVG string manipulation with DOMParser for the
  boostSvgForLargeGrid function, avoiding multi-MB temporary strings
- Index metadata with Maps for O(1) zoomToElement lookups instead of
  linear Array.find scans

https://claude.ai/code/session_01N3CzfYViwD4Xhs9aFnZafv
On large grids, the browser was laying out and painting thousands of
foreignObject labels, edge-info flow values, and edge text labels that
are completely unreadable at full zoom-out. This was the main cause of
slow initial rendering.

Fix: use CSS display:none to hide text elements by default on large
grids (marked with data-large-grid attribute by boostSvgForLargeGrid).
When the user zooms in past a threshold (viewBox < 3750 SVG units),
toggle the text-hidden class off to reveal labels. The toggle is a
single classList.toggle() in the applyViewBox hot path — near-zero cost.

A useLayoutEffect applies text-hidden before the first paint to prevent
any flash of unreadable text during initial load.

https://claude.ai/code/session_01N3CzfYViwD4Xhs9aFnZafv
The fixed TEXT_VISIBILITY_MAX_VIEWBOX (3750 SVG units) didn't work for
large grids where zoomToElement computes viewBoxes of 4000-6000 units
for a contingency neighborhood. Text stayed hidden even when zoomed in.

Fix: use a relative threshold — show text when viewing < 50% of the
original viewBox (i.e., zoomed in at least 2x). This scales with grid
size automatically: zoomToElement zooms to 10-30% of the grid, so text
now always appears on element focus.

https://claude.ai/code/session_01N3CzfYViwD4Xhs9aFnZafv
The data-large-grid attribute (which enables text-hiding) was set at
the same ratio threshold (>1.5) as the element boost. This caused
medium grids like Dijon (ratio ~1.6) to hide text at full zoom even
though it was perfectly readable, breaking their previous behavior.

Fix: set data-large-grid only at ratio > 3 (viewBox > 3750), where
25px text renders at ~8px on screen and is genuinely unreadable.
Medium grids (ratio 1.5-3) still get the boost for zoom-in readability
but keep text always visible — matching the pre-optimization behavior.

https://claude.ai/code/session_01N3CzfYViwD4Xhs9aFnZafv
Three fixes:

1. Raise boost threshold from ratio > 1.5 to ratio > 3 so small/medium
   grids (like Dijon) keep native pypowsybl sizing. Only genuinely large
   grids (viewBox > 3750) get the text/circle/edge-info boost and the
   text-hidden toggle.

2. Switch to N-1 tab immediately on contingency selection instead of
   waiting for the diagram to load. The user sees "Generating N-1
   Diagram..." while the fetch runs. The auto-zoom effect now defers
   lastZoomState update until the SVG container is ready, ensuring the
   zoom fires correctly when the diagram loads rather than being consumed
   by a failed attempt on an empty container.

3. Add AC load flow convergence check with DC fallback in get_n1_diagram
   (backend). If AC doesn't converge, falls back to DC — matching the
   existing pattern in run_analysis. This prevents NaN bus voltages from
   producing labels like "kV / -3°" with missing voltage magnitudes.

https://claude.ai/code/session_01N3CzfYViwD4Xhs9aFnZafv
…ence banner

Four fixes:

1. Normalized boost formula: sqrt(ratio/3) instead of sqrt(ratio), so
   grids just above the threshold get barely any visual change (ratio=4
   → boost 1.15x instead of 2.0x). Decoupled text-hidden (data-large-grid)
   to ratio > 6 so medium-large grids keep text always visible. Added
   console.log diagnostic for the ratio to help tune thresholds.

2. Reverted DC load flow fallback in get_n1_diagram — DC only computes
   angles/power, not voltage magnitudes, so running it after failed AC
   overwrites partial-but-useful AC results with emptier values. Now uses
   AC results as-is and returns lf_converged + lf_status in the response.

3. Fixed auto-zoom flashing on N tab: added guard to skip branch-triggered
   zoom when activeTab is still 'n' (batched setActiveTab('n-1') not yet
   committed in same render cycle). The zoom correctly defers until the
   N-1 tab is active and the diagram SVG is loaded.

4. Added convergence warning banner in N-1 tab — shows a yellow info bar
   with the load flow status when AC didn't converge.

https://claude.ai/code/session_01N3CzfYViwD4Xhs9aFnZafv
Three fixes:

1. Pan/zoom broken on N-1 after auto-zoom: the usePanZoom event
   registration effect only depended on [active], so events attached
   to the "Generating..." placeholder div (no SVG). When the diagram
   loaded and replaced the placeholder, events were stale. Added
   initialViewBox to the dependency array — events re-register when
   the diagram loads and the SVG container appears.

2. Multi-bus voltage levels (like PYMONP3): replaced per-circle r
   attribute scaling with group-level transforms. Now scales the entire
   parent <g> around the circle center, so both the outer circle AND
   inner path sectors (bus color segments) scale uniformly.

3. Boost threshold now uses voltage level count instead of viewBox ratio.
   Grids with < 500 VLs get no boost at all — the SVG is returned
   unchanged from pypowsybl. This definitively fixes small/medium grids
   regardless of their viewBox size. The VL count is passed from the
   API response through processSvg to boostSvgForLargeGrid.

https://claude.ai/code/session_01N3CzfYViwD4Xhs9aFnZafv
Console logs now show vlCount, viewBox dimensions, SVG width/height
attributes, container dimensions, and effective scaling factor. This
helps diagnose why small grids may render differently from native
pypowsybl output — the most likely cause is a viewBox change from a
pypowsybl library update rather than a code issue.

https://claude.ai/code/session_01N3CzfYViwD4Xhs9aFnZafv
@marota
marota merged commit 635af50 into claude/grid-zoom-scaling-UpSPy Feb 15, 2026
marota pushed a commit that referenced this pull request Feb 22, 2026
Addresses remaining lag when panning/zooming on large grid diagrams,
building on the foundation from PR #5.

Key optimizations:
- Batch wheel zoom events through rAF (was applying every wheel event
  directly, now accumulates scale factor and applies once per frame)
- Cache getScreenCTM() between frames to avoid layout recalculations
- Disable non-scaling-stroke during active interaction via CSS class
  toggle (the browser recalculates stroke widths for every SVG element
  on each viewBox change — this is the primary lag source)
- Disable pointer-events on SVG children during pan/zoom to skip
  hit-testing across thousands of elements
- Suppress drop-shadow filters during interaction (expensive compositing)
- Add CSS containment (contain: layout style paint) to SVG container
- Add hysteresis to text-visibility threshold (hide at 55%, show at 45%)
  to prevent flicker when zooming near the boundary

https://claude.ai/code/session_01E4knUJ3qRtqNKmqCT799Bn
marota pushed a commit that referenced this pull request May 2, 2026
Adds two layers of regression guards covering all five
recommendations from `docs/proposals/ui-design-critique.md`:

1. Seven new static invariants in `scripts/check_invariants.py`
   (Layer 4):
   - notices_panel_in_sidebar — AppSidebar mounts <NoticesPanel>
   - action_feed_no_dismissable_warning_state — ActionFeed lost
     setShowActionDictWarning / setShowRecommenderWarning
   - overload_panel_uses_monitoring_hint — OverloadPanel exposes
     `monitoringHint`, not the legacy banner props
   - diagram_legend_on_each_diagram_tab — VisualizationPanel wires
     <DiagramLegend tabId="n|n-1|action">
   - nad_overload_halo_capped_at_zoom — App.css caps overload halo
     stroke at 24px on detail-zoom via vector-effect: non-scaling-
     stroke
   - design_token_gate_blocks_inline_hex — FRONTEND_HEX_LITERAL_MAX
     stays at 0
   - action_card_progressive_disclosure_gated_by_isViewing —
     ActionCard renders the disclosure subtree under {isViewing &&

   Total: 10 → 17 invariants. All green.

2. New `frontend/src/uxConsistency.test.tsx` (19 specs) — runtime
   contract checks rendering the real components rather than
   mocks. One describe block per recommendation:
   - #1 design tokens: scans inline `style` attributes for raw hex
     literals and asserts none leak from OverloadPanel / AppSidebar
   - #2 progressive disclosure: ActionCard at-rest hides the
     disclosure subtree; isViewing=true reveals it
   - #3 halo cap: re-asserts the App.css rule (cheaper feedback
     loop than the static script)
   - #4 warning tier: OverloadPanel without monitoringHint shows
     no inline yellow banner; ActionFeed at default state has no
     warning-styled descendant; AppSidebar surfaces the
     <NoticesPanel> pill iff notices.length > 0
   - #5 diagram legend: each tab in VisualizationPanel mounts a
     legend pill when its SVG is loaded, omits it when absent
   - source-text invariants: cross-checks the file-level
     contracts so a refactor that drops a recommendation fails
     this file too

   1182 → 1201 specs. All passing; ESLint clean; code-quality
   gate clean.

https://claude.ai/code/session_01EYXsLY6hKRncRxj3KTHERk
marota added a commit that referenced this pull request May 5, 2026
``check_gesture_sequence.py``'s handler walker only matched the bare
``useCallback(`` wrapper when locating an arrow-function body. The
auto-generated standalone bundle emits the namespaced form
``reactExports.useCallback(`` (vite/rollup imports the hook through
a namespace object), so handlers like ``handleRunAnalysis`` that the
React source defines via ``useCallback`` were not detected on the
standalone side. The script then fell back to call-site scanning,
whose +2000-char forward window is too narrow to span the
``runAnalysisStep2Stream`` body — missing the
``analysis_step2_completed`` ``recordCompletion`` and producing a
spurious gesture-#5 failure.

Accept an optional ``<ident>.`` namespace prefix in front of
``useCallback`` so both the bare and dotted forms resolve to the
arrow-function body. Local run now reports 30/30 gestures passing
(was 29/30) and gestures 2, 4, 6, 7, 9–14 also resolve via the
stricter ``handler:*`` route instead of the looser ``callsite:*``
fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
marota pushed a commit that referenced this pull request Jun 18, 2026
Frontend coverage is now measurable (node_modules + @vitest/coverage-v8),
so set a real floor instead of leaving it report-only. vite.config.ts gains
`coverage.thresholds` a few points below the measured baseline (stmt 73.7 /
branch 70 / func 73.8 / line 76.3 → floors 70/65/70/73), validated locally
(npm run test:coverage exits 0). CI now runs the suite WITH coverage as the
gate (replacing the plain run — same tests + the floor) in both pipelines.

Backend coverage stays report-only: it can't be measured offline (several
tests need the real recommender), so its `--cov-fail-under` floor must be
read off the first green CI run, then ratcheted. Documented in §19.

https://claude.ai/code/session_0185GQf52QDpMM4zRDmGVN8X
marota pushed a commit that referenced this pull request Jun 18, 2026
Persists the measured scorecard and the outcomes of targets #1/#2/#3/#5/#6
(and the deliberate #4 deferral) in the analysis doc, and bumps the
"Last updated" header.

https://claude.ai/code/session_0185GQf52QDpMM4zRDmGVN8X
marota pushed a commit that referenced this pull request Jun 18, 2026
The first green CI run after §19 reported backend coverage 78%
(no-graphviz job: TOTAL 4771/1063 = 78%, 849 passed). Set the floor in
pyproject.toml ([tool.coverage.report] fail_under = 72) — 6 points below
baseline, the same margin as the frontend floor — so `pytest --cov` now
gates in both pipelines. Both coverage halves gate now; §20 documents it.

https://claude.ai/code/session_0185GQf52QDpMM4zRDmGVN8X
marota referenced this pull request in marota/Co-Study4Grid Jul 8, 2026
Closes the still-open half of headline #5 (the trust model no longer
matches deployment) and tightens the deploy pipeline.

Lockdown profile:
- COSTUDY4GRID_LOCKDOWN (read into main._LOCKDOWN) disables the desktop-
  era filesystem RPCs — POST /api/config-file-path, save/list/load-session,
  GET /api/pick-path — with a 403 {code: LOCKED_DOWN} via
  _reject_when_locked_down(). They assume a local operator and would give
  an anonymous Space visitor read/write access to the container.
- The read-only app config (GET user-config / config-file-path) stays
  open so the SPA still boots. Unset locally → behaviour unchanged.
- LOCKED_DOWN is a stable code in the D2 envelope (403 → LOCKED_DOWN) and
  the frontend ApiErrorCode union. The Dockerfile sets the flag.

Deploy hardening:
- deploy-huggingface.yml is now test-gated: it fires on workflow_run of a
  SUCCESSFUL "Tests" run on main (not a bare merge), and checks out that
  tested commit. workflow_dispatch stays as the rollback path.
- Each deploy tags the shipped commit on origin (space-deploy-<ts>-<sha>)
  — the only durable record, since the Space push is force-pushed and
  history-free. Rollback = re-dispatch from a prior tag.

Reproducible Python closure (pinned lockfile) is documented as a
follow-up in deployment-trust.md / SETUP.md — it must be resolved on the
image's Python 3.10, so shipping a dev-3.11 lockfile would be wrong.

Tests: TestLockdownProfile (403 + LOCKED_DOWN when locked, read-only
config still reachable, normal 200 when unlocked). OpenAPI snapshot
unaffected. Docs: new deployment-trust.md + index; review tracker (D7
mostly-done, headline #5, dimensions 9 & 12); CLAUDE.md trees;
SETUP.md rollback + lockfile sections; .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Am26qvtnHoY1qnmssaSQam
Signed-off-by: Antoine Marot <amarot91@gmail.com>
marota pushed a commit that referenced this pull request Jul 9, 2026
Lands the cheap halves of the two still-open headline issues (#4
invisible failures, #5 trust model) plus dead-code cleanup:

- QW4: surface useAnalysis.error through StatusToasts (error ||
  analysis.error, cleared on contingency-clear) so an analysis failure
  is no longer a silent dead spinner; the last silent SLD-preview catch
  now setError's too. Guarded by a new App-integration test.
- QW3: CORS default "*" -> loopback dev origins (localhost/127.0.0.1 on
  :5173/:4173); the wildcard is now explicit opt-in via
  CORS_ALLOWED_ORIGINS="*", closing the drive-by cross-origin read of a
  localhost backend.
- QW7: _safe_session_dir rejects "..", path separators and absolute
  session names before any filesystem write (resolve()+relative_to()
  backstop mirroring the /results/pdf guard); applied to save-session +
  load-session. Guarded by TestSessionPathTraversal.
- QW9: delete inspect_action.py, the dead expert_backend/requirements.txt
  stub, and the unused react-zoom-pan-pinch dependency (+ lockfile and
  doc follow-through). The tracked Overflow_Graph/*.html is deliberately
  kept: it is a skip-guarded regression fixture and untracking it would
  silently drop that CI coverage.

Docs (CLAUDE.md trees, README, .env.example) reconciled with the CORS
default change, the removed files, and the react-zoom-pan-pinch removal
(the "rendered inside react-zoom-pan-pinch" claims corrected to the
actual usePanZoom viewBox mechanism); review tracker updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Am26qvtnHoY1qnmssaSQam
Signed-off-by: Antoine Marot <amarot91@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants