Spaces:
Sleeping
Voltage-level disk interactions (NAD)
The voltage-level (VL) disks drawn by pypowsybl on the Network Area Diagram are directly interactive on every NAD tab — Network (N), Contingency and Remedial Action. Three gestures are wired onto each disk:
| Gesture | Effect |
|---|---|
| Hover | Shows the VL name in a small floating tooltip — but only while the on-diagram VL labels are hidden (the 🏷 VL toggle). When the labels are visible the name is already drawn, so the tooltip stays out of the way. |
| Single-click (disk) | Selects the VL: fills the bottom-left Inspect field with the VL id, auto-zooms / highlights it, and surfaces the 📄 SLD shortcut — exactly as typing the name into the Inspect box would. |
| Single-click (name box) | Opens the VL's Single Line Diagram overlay directly — the name label next to the disk is a second, larger click target for the SLD. |
| Double-click (disk or name box) | Opens the VL's Single Line Diagram overlay (the same entry point as the 📄 SLD button / onVlOpen). |
This is the disk-driven complement to the Inspect search field: the field finds an asset by name; the disk finds it by pointing at it.
The whole disk is a target, even under a branch
A branch is often drawn ON TOP of a VL disk, which would otherwise steal
the pointer hit-test from the disk beneath it. The handlers resolve this:
when the direct hit-test lands on something that is not a VL (an occluding
edge, or empty space), they fall back to document.elementsFromPoint and
take the first VL disk / name box in the paint stack under the cursor.
So the disk is interactive across its whole area regardless of what is
painted over it. The fallback only runs on discrete pointer events
(mousedown / hover-while-labels-hidden), never per frame, so the
performance contract below is unchanged.
The name box is resolved through the metadata
pypowsybl renders each VL name as a <div class="nad-label-box" id="…">
inside a root-level <foreignObject class="nad-text-nodes">. The box id
is the NAD metadata text-node svgId, which the metadata index
(metaIndex.textNodesBySvgId, built from metadata.textNodes via each
entry's vlNode link / equipmentId) maps to the same VL NodeMeta the
disk resolves to. A delegated handler climbs from the click target to the
first ancestor whose id is a known disk or text-node svgId.
Where it lives
frontend/src/utils/svg/vlInteractions.ts—attachVlInteractions(container, metaIndex, handlers). The whole behaviour is one function returning a teardown.frontend/src/App.tsx— a single effect binds the three NAD containers, mapping each gesture to the existing handlers:onSelect(vlId)→handleInspectQueryChangeFor(tab, vlId)(drives the per-tab Inspect query + auto-zoom inuseDiagrams). Fired by a single-click on the disk.onOpenSld(vlId)→handleVlOpen(vlId)→useSldOverlay.handleVlDoubleClick. Fired by a double-click on the disk / name box and by a single-click on the name box. The callbacks are held in refs (vlSelectRef/vlOpenSldRef/vlDisplayNameRef) so the listeners re-bind only when a diagram or its metadata index changes — never on an unrelated render, which would needlessly tear down the listeners and cancel an in-flight single-click timer.
frontend/src/App.css—.svg-container .nad-vl-nodes, .svg-container .nad-label-box { cursor: pointer }is the (static) pointer-cursor affordance on both the disk and the name box.
onSelect / onOpenSld reuse pre-existing interaction events
(inspect_query_changed, sld_overlay_opened), so the replay log stays
complete with no new event types.
How a disk is resolved
pypowsybl renders each VL under <g class="nad-vl-nodes"> as a group
<g id="{svgId}"><circle r="27.5"/>…</g>. The diagram metadata index
(metaIndex.nodesBySvgId) maps that svgId to the VL's equipmentId
(the VL id used everywhere else — voltageLevels, the SLD endpoints, the
zoom-to-element lookup). A delegated handler climbs from the event target
up to the first ancestor whose id is a known node svgId, yielding the
VL.
Performance contract (do not regress)
Pan/zoom fluidity is the hard constraint — the NAD can carry ~5 000 VLs / ~100 k DOM nodes. The design is built around touching nothing per frame:
- Event delegation. A fixed handful of listeners on the container
(
mouseover,mouseout,mousedown,click,dblclick), never one per node. A 5 000-VL grid costs the same to wire as a 5-VL one. - No
mousemove/ no per-frame work. The tooltip is positioned once onmouseover; the cursor is a static CSS rule. - Idle during gestures.
usePanZoomadds.svg-interactingduring a pan/zoom, which setspointer-events: noneon every SVG child (App.css), so none of the disk handlers fire mid-gesture. Pan/zoom is a directviewBoxrewrite (no React re-render per frame), and this layer adds nothing to it.
The click-vs-pan subtlety (regression-tested)
Because .svg-interacting flips pointer-events: none on SVG children
the instant a press starts, by the time mouseup fires the disk is
transparent and the browser retargets the resulting click /
dblclick to the container (the common ancestor of the disk-mousedown
and the container-mouseup). Resolving the VL from click.target would
therefore always miss — this is exactly the bug where hover worked but
clicks did nothing.
The fix: capture the VL on mousedown, whose hit-test still lands on
the live disk (the cull class is only set by that handler, after the
target is already fixed), and use the captured node in the click /
double-click handlers. A press is told apart from a pan by pointer travel
(DRAG_THRESHOLD_PX), and the single-click action is deferred
VL_SINGLE_CLICK_DELAY_MS (250 ms) so a double-click pre-empts it.
Tests
frontend/src/utils/svg/vlInteractions.test.ts— unit coverage for the module: single-click →onSelect, double-click →onOpenSld(and single-click suppression), the container-retargeted click / dbl-click (the real-worldpointer-eventscull), the drag guard, hover-tooltip gating onnad-hide-vl-labels,displayNamefallback, same-group mouseout, single-tooltip reuse, nested-child resolution, multi-VL disambiguation, teardown (listeners + tooltip + pending timer), and the no-op guards.frontend/src/App.stateManagement.test.tsx(Voltage-level disk interaction wiring) — captures the handlers App passes toattachVlInteractionsand invokes them, assertingonSelectflows into the shared Inspect query andonOpenSldopens the SLD overlay for the VL.
History
Supersedes the former native <title> tooltip injector
(utils/svg/vlTitles.ts, applyVlTitles, removed): the native title only
delivered hover-name; the unified layer delivers hover + click + double-
click with an immediate, theme-aware tooltip and no double tooltip.