diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 0699d20..6673874 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -3,8 +3,10 @@ name: Code Quality on: push: branches: [main] + # Run on every PR regardless of its base branch (e.g. PRs stacked onto a + # `claude/*` working branch, not just those targeting main). pull_request: - branches: [main] + branches: ['**'] jobs: gate: diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index c6c17cd..9180b81 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -26,8 +26,10 @@ name: Parity on: push: branches: [main] + # Run on every PR regardless of its base branch (e.g. PRs stacked onto a + # `claude/*` working branch, not just those targeting main). pull_request: - branches: [main] + branches: ['**'] schedule: # Nightly at 02:30 UTC — catches drift introduced between PRs # (e.g. docs/features/interaction-logging.md updates without a code change). diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 88b923e..f19746f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,10 @@ name: Tests on: push: branches: [ main ] + # Run on every PR regardless of its base branch (e.g. PRs stacked onto a + # `claude/*` working branch, not just those targeting main). pull_request: - branches: [ main ] + branches: [ '**' ] jobs: # ------------------------------------------------------------------ diff --git a/docs/features/sld-diagram-feeder-labels.md b/docs/features/sld-diagram-feeder-labels.md index f7c74a2..5693fe1 100644 --- a/docs/features/sld-diagram-feeder-labels.md +++ b/docs/features/sld-diagram-feeder-labels.md @@ -29,6 +29,41 @@ operator reads *where the line goes*, e.g. `MARSILLON 225kV`). (original stashed in `data-feeder-orig`, restored on tab/VL switch, highlight clones skipped) — the same render-every-time + self-gate pattern as the other SLD label passes. +- **Long labels wrap instead of occluding neighbours.** A relabelled name + wider than ~15 chars (`wrapFeederLabel`) is split onto up to three `` + lines, vertically centred on the original baseline (first line lifted by half + the block height) so it spreads up AND down rather than overprinting the + adjacent feeder's label — the fix for the top/bottom label pile-up on dense + VLs like `VL_way_207479669-225`. Wrapping breaks on whitespace first and, for + a long single word with no spaces (a raw IIDM id like + `L_virtual_relation_8423568_a_0-225`), on its `_` / `-` / `.` separators. +- **Every long feeder NAME wraps, not just the relabelled branches.** + `applyFeederLabelWrap` (in `feederLabels.ts`, run right after + `applyFeederRelabels` from `useSldFeederRelabel`) wraps the remaining long + feeder names at a substation's extremities — generators, loads, and branches + whose far-end VL is unnamed (so they were never relabelled) — which is the + overlap that survived Issue 1's branch-only relabelling. It targets the + equipment-name `` inside each feeder cell (`.sld-extern-cell` / + `.sld-intern-cell` / `.sld-shunt-cell`), skipping the numeric P/Q flow labels, + the already-relabelled feeders (which wrap themselves), and highlight clones. + Idempotent via `data-feeder-wrap` / `data-feeder-wrap-orig`. It runs BEFORE + the injection-name-button pass so those buttons size their box to the wrapped + multi-line name. + +## 1b. Navigate to a branch's other extremity by clicking its feeder name + +Each relabelled feeder is tagged with `data-feeder-nav` = the far-end VL id +(the `other_vl` field) and the `sld-feeder-navigable` class (dotted underline ++ pointer cursor). `hooks/useSldFeederNav.ts` installs one delegated +capture-phase click listener on the overlay body: a (non-pan) click on a +feeder name hands `other_vl` to `onNavigateToVl`, which re-opens the SLD for +that VL keeping the current sub-tab (`App.handleSldNavigateToVl` → +`handleVlDoubleClick(actionId, other_vl, tab)`). Because the contingency / +overload halo pass runs against whatever VL is displayed, the overload opened +at one end stays highlighted from the other end after the jump. The listener +uses `stopImmediatePropagation` and is registered before the edit-mode +switch/injection handler (which also early-bails on `[data-feeder-nav]`) so a +name click never doubles as a topology maneuver. ## 2. Overload halo visible on the extremity SLD @@ -85,6 +120,8 @@ the card annotates it with the live-end reactive power. - Backend: `test_feeder_labels.py`, `test_half_open_overload.py`, and the `build_half_open_reactive` cases in `test_simulation_helpers.py`. -- Frontend: `utils/svg/feederLabels.test.ts`, the "feeder relabelling" / - "overload halo via friendly name" suites in `components/SldOverlay.test.tsx`, - and the "half-open overload annotation" suite in `components/ActionCard.test.tsx`. +- Frontend: `utils/svg/feederLabels.test.ts` (relabel + `wrapFeederLabel` + + `data-feeder-nav` tagging), the "feeder relabelling" / "overload halo via + friendly name" / feeder-name navigation suites in + `components/SldOverlay.test.tsx`, and the "half-open overload annotation" + suite in `components/ActionCard.test.tsx`. diff --git a/docs/features/vl-disk-interactions.md b/docs/features/vl-disk-interactions.md index 18ea496..6d31ace 100644 --- a/docs/features/vl-disk-interactions.md +++ b/docs/features/vl-disk-interactions.md @@ -8,12 +8,35 @@ 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** | 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. | -| **Double-click** | Opens the VL's Single Line Diagram overlay (the same entry point as the `📄 SLD` button / `onVlOpen`). | +| **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 `
` +inside a root-level ``. 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)`. @@ -21,15 +44,19 @@ field finds an asset by name; the disk finds it by pointing at it. - **`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 in `useDiagrams`). + the per-tab Inspect query + auto-zoom in `useDiagrams`). 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 { cursor: pointer }` - is the (static) pointer-cursor affordance. +- **`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 diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 211b969..d1775fd 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -73,9 +73,16 @@ frontend/ │ ├── useTheme.ts # Light/dark theme toggle + persistence │ │ # (0.8.0; see docs/features/dark-mode.md) │ ├── useSldFeederRelabel.ts # Relabel SLD branch feeders with the far- - │ │ # end VL name (Issue 1; render-every-time - │ │ # self-gate, delegates the DOM swap to - │ │ # utils/svg/feederLabels.applyFeederRelabels) + │ │ # end VL name + wrap EVERY long feeder name + │ │ # (generators / loads / unmatched branches) + │ │ # so extremity names stop overlapping (Issue + │ │ # 1; render-every-time self-gate, delegates + │ │ # the DOM swap to feederLabels + │ │ # .applyFeederRelabels + .applyFeederLabelWrap) + │ ├── useSldFeederNav.ts # Click a relabelled feeder name → open the + │ │ # far-end VL's SLD (reads data-feeder-nav; + │ │ # capture-phase, keeps the current sub-tab so + │ │ # the overload stays visible from both ends) │ └── useSldInjectionNameButtons.ts # Render editable-injection NAME │ # buttons on the SLD (extracted from │ # SldOverlay to keep it under the LoC ceiling) diff --git a/frontend/src/App.css b/frontend/src/App.css index 803f2d7..5feed86 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -178,11 +178,13 @@ This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface t /* Voltage-level disks are interactive: single-click selects (Inspect + auto-zoom), double-click opens the SLD, hover shows the name while the - static labels are hidden (see utils/svg/vlInteractions.ts). The pointer + static labels are hidden (see utils/svg/vlInteractions.ts). The VL name + box is interactive too — a single click opens the SLD. The pointer cursor is a static rule so the affordance costs nothing at render or gesture time; during a pan/zoom `.svg-interacting` disables SVG hit- testing, so the container's grab/grabbing cursor wins instead. */ -.svg-container .nad-vl-nodes { +.svg-container .nad-vl-nodes, +.svg-container .nad-label-box { cursor: pointer; } @@ -426,6 +428,18 @@ circle.nad-highlight { cursor: pointer; } +/* Relabelled branch feeder names are clickable: a click navigates the SLD + overlay to that voltage level (the branch's other extremity). The dashed + underline + pointer cursor signal the affordance without recolouring. */ +.sld-feeder-navigable { + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; +} +.sld-feeder-navigable:hover { + fill: var(--color-brand-strong) !important; +} + /* Injection name rendered as a button (same dark blue + white text as the former "Manual action" button): a rounded rect injected behind the name , with the label recoloured on top. */ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 117ef8b..b199570 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1609,6 +1609,14 @@ function App() { handleVlDoubleClick(selectedActionId || '', vlName); }, [handleVlDoubleClick, selectedActionId]); + // Clicking a (relabelled) feeder name on the SLD jumps to the far-end VL's + // SLD, keeping the current sub-tab so the same contingency / overload stays + // in view from the other extremity. + const handleSldNavigateToVl = useCallback((vlId: string) => { + if (!vlOverlay) return; + handleVlDoubleClick(vlOverlay.actionId || selectedActionId || '', vlId, vlOverlay.tab); + }, [vlOverlay, handleVlDoubleClick, selectedActionId]); + // Keep the VL-disk interaction callbacks in refs so the delegated // listeners below re-bind ONLY when a diagram / its metadata actually // changes — never on an unrelated App render, which would needlessly @@ -2024,6 +2032,7 @@ function App() { onSldSwitchFocus={sldTopologyEdit.setFocusedSwitch} onSldSwitchRemove={sldTopologyEdit.removeSwitch} onSldSwitchRemoveMany={sldTopologyEdit.removeSwitches} + onSldNavigateToVl={handleSldNavigateToVl} voltageLevels={voltageLevels} onVlOpen={handleVlOpen} onOverflowPinPreview={handlePinPreview} diff --git a/frontend/src/components/SldOverlay.test.tsx b/frontend/src/components/SldOverlay.test.tsx index bb985bb..2c332bd 100644 --- a/frontend/src/components/SldOverlay.test.tsx +++ b/frontend/src/components/SldOverlay.test.tsx @@ -1352,14 +1352,47 @@ describe('SldOverlay', () => { expect(lbl?.getAttribute('data-feeder-orig')).toBe('relation_8423569-225'); }); - it('preserves a parallel-circuit index in the label', () => { + it('preserves a parallel-circuit index in the label (wrapped over lines)', () => { const overlay = buildRelabelOverlay({ 'relation_8423569-225': { name: 'LANNEL61PRAGN', other_vl: 'VL_LANNE', label: 'LANNEMEZAN 225kV 2', }, }); const { container } = render(); - expect(container.querySelector('#lbl_marsil')?.textContent).toBe('LANNEMEZAN 225kV 2'); + const lbl = container.querySelector('#lbl_marsil')!; + // Long labels wrap onto lines so they don't occlude a + // neighbouring feeder; the full label (index included) is preserved. + const tspans = Array.from(lbl.querySelectorAll('tspan')); + expect(tspans.length).toBeGreaterThan(1); + expect(tspans.map(t => t.textContent).join(' ')).toBe('LANNEMEZAN 225kV 2'); + }); + + it('tags a relabelled feeder for navigation to the far-end VL', () => { + const overlay = buildRelabelOverlay({ + 'relation_8423569-225': { + name: 'MARSIL61PRAGN', other_vl: 'VL_MARSIL', label: 'MARSILLON 225kV', + }, + }); + const { container } = render(); + const lbl = container.querySelector('#lbl_marsil'); + expect(lbl?.getAttribute('data-feeder-nav')).toBe('VL_MARSIL'); + expect(lbl?.classList.contains('sld-feeder-navigable')).toBe(true); + }); + + it('navigates to the far-end VL when a feeder name is clicked', () => { + const overlay = buildRelabelOverlay({ + 'relation_8423569-225': { + name: 'MARSIL61PRAGN', other_vl: 'VL_MARSIL', label: 'MARSILLON 225kV', + }, + }); + const onNavigateToVl = vi.fn(); + const { container } = render( + , + ); + container.querySelector('#lbl_marsil')!.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + expect(onNavigateToVl).toHaveBeenCalledWith('VL_MARSIL'); }); it('leaves the id untouched when the label is null', () => { diff --git a/frontend/src/components/SldOverlay.tsx b/frontend/src/components/SldOverlay.tsx index cc33208..5d67ba0 100644 --- a/frontend/src/components/SldOverlay.tsx +++ b/frontend/src/components/SldOverlay.tsx @@ -10,6 +10,7 @@ import type { DiagramData, AnalysisResult, ActionDetail, VlOverlay, SldTab, SldF import { isCouplingAction } from '../utils/svgUtils'; import { buildFriendlyToEquip, overloadCandidates } from '../utils/svg/feederLabels'; import { useSldFeederRelabel } from '../hooks/useSldFeederRelabel'; +import { useSldFeederNav } from '../hooks/useSldFeederNav'; import { useSldInjectionNameButtons } from '../hooks/useSldInjectionNameButtons'; import { colors } from '../styles/tokens'; import SldEditPanel from './SldEditPanel'; @@ -93,6 +94,14 @@ export interface SldOverlayProps { onSwitchFocus?: (equipmentId: string | null) => void; onSwitchRemove?: (equipmentId: string) => void; onSwitchRemoveMany?: (equipmentIds: string[]) => void; + /** + * Navigate to the voltage level at a branch extremity. Fired when the + * operator clicks a (relabelled) feeder name — the far-end VL id is + * resolved from ``data-feeder-nav`` (set by ``applyFeederRelabels``). The + * host re-opens the SLD for that VL keeping the current tab, so the same + * overload stays visible from the other end. + */ + onNavigateToVl?: (vlId: string) => void; } const SldOverlay: React.FC = ({ @@ -121,6 +130,7 @@ const SldOverlay: React.FC = ({ onSwitchFocus, onSwitchRemove, onSwitchRemoveMany, + onNavigateToVl, }) => { const overlayBodyRef = useRef(null); // True once a press-drag has moved the pointer beyond a small slop @@ -1024,13 +1034,21 @@ const SldOverlay: React.FC = ({ } }); + // Relabel branch feeders with the far-end VL name + wrap long feeder names + // (Issue 1) — pass + its self-gate live in the hook (see + // utils/svg/feederLabels.ts for the swap). Runs BEFORE the injection-name + // buttons below so those compute their bounding box on the wrapped, + // multi-line name rather than the original single line. + useSldFeederRelabel(overlayBodyRef, vlOverlay.feeder_labels, activeSvg, vlOverlay.tab, vlOverlay.actionId, previewActive); + // Render every editable injection's NAME as a clickable button (opens the // active-power editor) — pass + its self-gate live in the hook. useSldInjectionNameButtons(overlayBodyRef, vlOverlay.injections, editMode, activeSvg, vlOverlay.tab, vlOverlay.actionId, previewActive); - // Relabel branch feeders with the far-end VL name (Issue 1) — pass + its - // self-gate live in the hook (see utils/svg/feederLabels.ts for the swap). - useSldFeederRelabel(overlayBodyRef, vlOverlay.feeder_labels, activeSvg, vlOverlay.tab, vlOverlay.actionId, previewActive); + // Click a relabelled feeder name → open the far-end VL's SLD. Registered + // BEFORE the edit-mode click effect below so its stop-immediate wins on a + // shared capture-phase click. + useSldFeederNav(overlayBodyRef, onNavigateToVl, panMovedRef); // Delegate switch clicks on the SVG body when edit mode is on. // We attach to the body container (capture phase) so taps on the @@ -1114,6 +1132,10 @@ const SldOverlay: React.FC = ({ // native listener, so we filter here. if (target.closest('[data-testid="sld-injection-popover"]')) return; + // A feeder-name click navigates to the far-end VL (handled by + // useSldFeederNav) — never treat it as a topology maneuver. + if (target.closest('[data-feeder-nav]')) return; + // Injection NAME button (rect / label tagged with // data-injection-equip) — opens the active-power editor. const nameHit = target.closest('[data-injection-equip]'); diff --git a/frontend/src/components/VisualizationPanel.tsx b/frontend/src/components/VisualizationPanel.tsx index ba83fc7..fe395d5 100644 --- a/frontend/src/components/VisualizationPanel.tsx +++ b/frontend/src/components/VisualizationPanel.tsx @@ -121,6 +121,8 @@ interface VisualizationPanelProps { onSldSwitchFocus?: (equipmentId: string | null) => void; onSldSwitchRemove?: (equipmentId: string) => void; onSldSwitchRemoveMany?: (equipmentIds: string[]) => void; + /** Navigate the SLD overlay to a branch extremity's voltage level. */ + onSldNavigateToVl?: (vlId: string) => void; voltageLevels: string[]; onVlOpen: (vlName: string) => void; /** @@ -283,6 +285,7 @@ const VisualizationPanel: React.FC = ({ onSldSwitchFocus, onSldSwitchRemove, onSldSwitchRemoveMany, + onSldNavigateToVl, voltageLevels, onVlOpen, onOverflowPinPreview, @@ -1404,6 +1407,7 @@ const VisualizationPanel: React.FC = ({ onSwitchFocus={onSldSwitchFocus} onSwitchRemove={onSldSwitchRemove} onSwitchRemoveMany={onSldSwitchRemoveMany} + onNavigateToVl={onSldNavigateToVl} /> )} diff --git a/frontend/src/hooks/useSldFeederNav.ts b/frontend/src/hooks/useSldFeederNav.ts new file mode 100644 index 0000000..4227e97 --- /dev/null +++ b/frontend/src/hooks/useSldFeederNav.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) +// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +// you can obtain one at http://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 +// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. + +import { useEffect, type RefObject } from 'react'; + +/** + * Navigate to a branch extremity's voltage level by clicking its (relabelled) + * feeder name on the SLD. ``applyFeederRelabels`` tags each navigable feeder + * label with ``data-feeder-nav`` = the far-end VL id; a delegated capture-phase + * listener on the overlay body resolves that id and hands it to + * ``onNavigateToVl`` (which re-opens the SLD for that VL, keeping the current + * tab so the same overload stays in view from the other end). + * + * Capture phase + a stop-immediate so the edit-mode switch/injection click + * handler on the same container never also fires for a label click. A press + * that moved (a pan, tracked in ``panMovedRef``) is ignored, matching the + * switch-click gesture discrimination. + */ +export function useSldFeederNav( + bodyRef: RefObject, + onNavigateToVl: ((vlId: string) => void) | undefined, + panMovedRef: RefObject, +): void { + useEffect(() => { + const container = bodyRef.current; + if (!container || !onNavigateToVl) return; + const onClick = (ev: MouseEvent) => { + if (panMovedRef.current) return; + const target = ev.target as Element | null; + const hit = target?.closest('[data-feeder-nav]'); + if (!hit) return; + const vlId = hit.getAttribute('data-feeder-nav'); + if (!vlId) return; + ev.stopImmediatePropagation(); + ev.preventDefault(); + onNavigateToVl(vlId); + }; + container.addEventListener('click', onClick, true); + return () => container.removeEventListener('click', onClick, true); + }, [bodyRef, onNavigateToVl, panMovedRef]); +} diff --git a/frontend/src/hooks/useSldFeederRelabel.ts b/frontend/src/hooks/useSldFeederRelabel.ts index f9cbb0a..42f4112 100644 --- a/frontend/src/hooks/useSldFeederRelabel.ts +++ b/frontend/src/hooks/useSldFeederRelabel.ts @@ -7,12 +7,14 @@ import { useLayoutEffect, useRef, type RefObject } from 'react'; import type { FeederLabel, SldTab } from '../types'; -import { applyFeederRelabels } from '../utils/svg/feederLabels'; +import { applyFeederRelabels, applyFeederLabelWrap } from '../utils/svg/feederLabels'; /** - * Relabel SLD branch feeders with the far-end VL name (Issue 1). Owns the - * render-every-time + signature self-gate so a pan reconciliation that drops - * the relabel re-applies it, mirroring the other SldOverlay label passes. + * Relabel SLD branch feeders with the far-end VL name (Issue 1) AND wrap every + * remaining long feeder NAME (generators / loads / unmatched branches) so the + * names stop overlapping at the substation extremities. Owns the + * render-every-time + self-gate so a pan reconciliation that drops either pass + * re-applies it, mirroring the other SldOverlay label passes. */ export function useSldFeederRelabel( bodyRef: RefObject, @@ -22,7 +24,7 @@ export function useSldFeederRelabel( actionId: string | null, preview: boolean, ): void { - const sigRef = useRef(''); + const stateRef = useRef({ sig: '', relabel: false, wrap: false }); useLayoutEffect(() => { const container = bodyRef.current; if (!container) return; @@ -36,10 +38,20 @@ export function useSldFeederRelabel( preview, labels: entries.map(([eid, info]) => `${eid}=${info.label}`).sort(), }); - const applied = container.querySelector('[data-feeder-relabel]') !== null; - const expect = entries.length > 0 && !!activeSvg; - if (sig === sigRef.current && (expect ? applied : !applied)) return; - sigRef.current = sig; + const relabelPresent = container.querySelector('[data-feeder-relabel]') !== null; + const wrapPresent = container.querySelector('[data-feeder-wrap]') !== null; + // Re-run when the inputs changed, or when a reconciliation dropped the + // marks we last produced (the DOM diverged from our recorded state). + const consistent = + stateRef.current.relabel === relabelPresent && + stateRef.current.wrap === wrapPresent; + if (sig === stateRef.current.sig && consistent) return; applyFeederRelabels(container, feederLabels, activeSvg); + applyFeederLabelWrap(container, activeSvg); + stateRef.current = { + sig, + relabel: container.querySelector('[data-feeder-relabel]') !== null, + wrap: container.querySelector('[data-feeder-wrap]') !== null, + }; }); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f50f881..e978a9c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -308,6 +308,10 @@ export interface MetadataIndex { nodesBySvgId: Map; edgesByEquipmentId: Map; edgesByNode: Map; + /** VL text-label (name box) svgId → its VL NodeMeta, so the label box can + * be interactive (open the SLD) exactly like the VL disk. Optional because + * the many hand-built MetadataIndex test fixtures omit it. */ + textNodesBySvgId?: Map; } export interface DiagramPatch { diff --git a/frontend/src/utils/svg/feederLabels.test.ts b/frontend/src/utils/svg/feederLabels.test.ts index 2e759aa..3011bc4 100644 --- a/frontend/src/utils/svg/feederLabels.test.ts +++ b/frontend/src/utils/svg/feederLabels.test.ts @@ -5,7 +5,7 @@ // SPDX-License-Identifier: MPL-2.0 import { describe, it, expect } from 'vitest'; -import { buildFriendlyToEquip, overloadCandidates, applyFeederRelabels } from './feederLabels'; +import { buildFriendlyToEquip, overloadCandidates, applyFeederRelabels, applyFeederLabelWrap, wrapFeederLabel } from './feederLabels'; import type { FeederLabel } from '../../types'; const fl = (over: Partial> = {}): Record => ({ @@ -46,6 +46,37 @@ describe('overloadCandidates', () => { }); }); +describe('wrapFeederLabel', () => { + it('keeps a short label on a single line', () => { + expect(wrapFeederLabel('MARSILLON 225kV')).toEqual(['MARSILLON 225kV']); + }); + + it('keeps a short single word (no spaces) on one line', () => { + expect(wrapFeederLabel('relation_842')).toEqual(['relation_842']); + }); + + it('breaks a long single word on its separators and preserves every char', () => { + const lines = wrapFeederLabel('virtual_relation_8423568_a_0-225'); + expect(lines.length).toBeGreaterThan(1); + expect(lines.length).toBeLessThanOrEqual(3); + expect(lines.join('')).toBe('virtual_relation_8423568_a_0-225'); + // Each line stays within the wrap budget (last one may fold overflow). + expect(lines[0].length).toBeLessThanOrEqual(15); + }); + + it('wraps a long label on spaces and preserves every token', () => { + const lines = wrapFeederLabel('LANNEMEZAN 225kV 2'); + expect(lines.length).toBeGreaterThan(1); + expect(lines.join(' ')).toBe('LANNEMEZAN 225kV 2'); + }); + + it('caps at three lines, folding the overflow into the last one', () => { + const lines = wrapFeederLabel('ONE TWO THREE FOUR FIVE SIX SEVEN EIGHT'); + expect(lines.length).toBeLessThanOrEqual(3); + expect(lines.join(' ')).toBe('ONE TWO THREE FOUR FIVE SIX SEVEN EIGHT'); + }); +}); + describe('applyFeederRelabels', () => { const mount = (svg: string): HTMLElement => { const div = document.createElement('div'); @@ -87,4 +118,115 @@ describe('applyFeederRelabels', () => { applyFeederRelabels(container, fl(), null); expect(container.querySelector('#t')!.textContent).toBe('relation_8423569-225'); }); + + it('tags the relabelled feeder with the far-end VL id for navigation', () => { + const container = mount('relation_8423569-225'); + applyFeederRelabels(container, fl(), ''); + const t = container.querySelector('#t')!; + expect(t.getAttribute('data-feeder-nav')).toBe('VL_MARSIL'); + expect(t.classList.contains('sld-feeder-navigable')).toBe(true); + }); + + it('drops the navigation tag on restore', () => { + const container = mount('relation_8423569-225'); + applyFeederRelabels(container, fl(), ''); + applyFeederRelabels(container, {}, ''); + const t = container.querySelector('#t')!; + expect(t.getAttribute('data-feeder-nav')).toBeNull(); + expect(t.classList.contains('sld-feeder-navigable')).toBe(false); + }); + + it('does not tag navigation when the far-end VL is unknown', () => { + const container = mount('relation_8423569-225'); + applyFeederRelabels(container, { + 'relation_8423569-225': { name: 'X', other_vl: null, label: 'X' }, + }, ''); + expect(container.querySelector('#t')!.getAttribute('data-feeder-nav')).toBeNull(); + }); +}); + +describe('applyFeederLabelWrap', () => { + const mount = (svg: string): HTMLElement => { + const div = document.createElement('div'); + div.innerHTML = svg; + return div; + }; + + it('wraps a long generator / load name inside a feeder cell', () => { + const container = mount( + '' + + 'G_virtual_relation_8423568_a_0-225' + + '', + ); + applyFeederLabelWrap(container, ''); + const t = container.querySelector('#g')!; + expect(t.getAttribute('data-feeder-wrap')).toBe('1'); + expect(t.querySelectorAll('tspan').length).toBeGreaterThan(1); + expect(t.getAttribute('data-feeder-wrap-orig')).toBe('G_virtual_relation_8423568_a_0-225'); + }); + + it('leaves a short name untouched', () => { + const container = mount( + 'IGNERES 225kV', + ); + applyFeederLabelWrap(container, ''); + const t = container.querySelector('#s')!; + expect(t.hasAttribute('data-feeder-wrap')).toBe(false); + expect(t.textContent).toBe('IGNERES 225kV'); + }); + + it('skips the numeric P/Q flow labels', () => { + const container = mount( + '' + + '-1234567.89' + + '-16.8 MVAr' + + '', + ); + applyFeederLabelWrap(container, ''); + expect(container.querySelector('#p')!.hasAttribute('data-feeder-wrap')).toBe(false); + expect(container.querySelector('#q')!.hasAttribute('data-feeder-wrap')).toBe(false); + }); + + it('leaves an already-relabelled feeder alone (relabel wraps its own)', () => { + const container = mount( + '' + + 'LANNEMEZAN 225kV 1' + + '', + ); + applyFeederLabelWrap(container, ''); + expect(container.querySelector('#r')!.hasAttribute('data-feeder-wrap')).toBe(false); + }); + + it('ignores long text outside a feeder cell (e.g. the busbar label)', () => { + const container = mount( + 'VL_virtual_relation_8423568_a_0-225_BBS1', + ); + applyFeederLabelWrap(container, ''); + expect(container.querySelector('#bb')!.hasAttribute('data-feeder-wrap')).toBe(false); + }); + + it('restores the original text on a subsequent call with no svg', () => { + const container = mount( + '' + + 'G_virtual_relation_8423568_a_0-225' + + '', + ); + applyFeederLabelWrap(container, ''); + applyFeederLabelWrap(container, null); + const t = container.querySelector('#g')!; + expect(t.hasAttribute('data-feeder-wrap')).toBe(false); + expect(t.textContent).toBe('G_virtual_relation_8423568_a_0-225'); + }); + + it('skips highlight clones', () => { + const container = mount( + '' + + 'G_virtual_relation_8423568_a_0-225' + + 'G_virtual_relation_8423568_a_0-225' + + '', + ); + applyFeederLabelWrap(container, ''); + expect(container.querySelector('#orig')!.getAttribute('data-feeder-wrap')).toBe('1'); + expect(container.querySelector('.sld-highlight-clone text')!.hasAttribute('data-feeder-wrap')).toBe(false); + }); }); diff --git a/frontend/src/utils/svg/feederLabels.ts b/frontend/src/utils/svg/feederLabels.ts index a95efbe..0b3786c 100644 --- a/frontend/src/utils/svg/feederLabels.ts +++ b/frontend/src/utils/svg/feederLabels.ts @@ -41,13 +41,105 @@ export function overloadCandidates( return mapped && mapped.length > 0 ? [lineId, ...mapped] : [lineId]; } +const SVG_NS = 'http://www.w3.org/2000/svg'; +/** Wrap a relabelled feeder onto multiple lines past this width so a long + * far-end VL name (e.g. "LANNEMEZAN 225kV 1") stops overprinting the adjacent + * feeder's label at the top / bottom of the SLD. */ +const FEEDER_WRAP_CHARS = 15; +const FEEDER_MAX_LINES = 3; + +/** + * Break a single word wider than ``budget`` into pieces no wider than it, + * preferring a natural separator (``_`` ``-`` ``.``) at or before the budget so + * an id like ``virtual_relation_8423568`` splits between segments rather than + * mid-token; falls back to a hard character cut when a chunk has no separator. + * The separator stays attached to the preceding piece so the pieces re-join + * exactly into the original word. + */ +function breakLongWord(word: string, budget: number): string[] { + if (word.length <= budget) return [word]; + const pieces: string[] = []; + let rest = word; + while (rest.length > budget) { + let cut = -1; + for (let i = Math.min(budget, rest.length - 1); i >= 1; i--) { + const ch = rest[i]; + if (ch === '_' || ch === '-' || ch === '.') { cut = i + 1; break; } + } + if (cut <= 0) cut = budget; // no separator in range → hard split + pieces.push(rest.slice(0, cut)); + rest = rest.slice(cut); + } + if (rest) pieces.push(rest); + return pieces; +} + +/** + * Break a feeder label into at most ``FEEDER_MAX_LINES`` lines no wider than + * ``FEEDER_WRAP_CHARS``. Splits on whitespace first, and a single word still + * wider than the budget (a long raw IIDM id — ``L_virtual_relation_8423568…``) + * is further broken on its ``_`` / ``-`` / ``.`` separators so it stops running + * horizontally off its feeder and overprinting the neighbour. Overflow past the + * line cap is folded back into the last line. + */ +export function wrapFeederLabel(label: string): string[] { + if (label.length <= FEEDER_WRAP_CHARS) return [label]; + const lines: string[] = []; + let cur = ''; + for (const word of label.split(/\s+/).filter(Boolean)) { + // A word that still fits on the current line joins it with a space. + if (cur && (cur + ' ' + word).length <= FEEDER_WRAP_CHARS) { + cur += ' ' + word; + continue; + } + if (cur) { lines.push(cur); cur = ''; } + const pieces = breakLongWord(word, FEEDER_WRAP_CHARS); + for (let i = 0; i < pieces.length - 1; i++) lines.push(pieces[i]); + cur = pieces[pieces.length - 1]; + } + if (cur) lines.push(cur); + if (lines.length === 0) return [label]; + if (lines.length > FEEDER_MAX_LINES) { + const head = lines.slice(0, FEEDER_MAX_LINES - 1); + head.push(lines.slice(FEEDER_MAX_LINES - 1).join(' ')); + return head; + } + return lines; +} + +/** + * Write ``label`` into ``textEl``, wrapping onto multiple ```` lines when + * it is long. The block is vertically centred on the original baseline (first + * line lifted by half the block height) so the wrapped label sits where the + * single-line id used to, spreading up AND down rather than only downward into + * the diagram. + */ +function setFeederText(textEl: SVGTextElement, label: string): void { + const lines = wrapFeederLabel(label); + if (lines.length <= 1) { + textEl.textContent = label; + return; + } + const x = textEl.getAttribute('x') ?? '0'; + textEl.textContent = ''; + lines.forEach((line, i) => { + const tspan = document.createElementNS(SVG_NS, 'tspan'); + tspan.setAttribute('x', x); + tspan.setAttribute('dy', i === 0 ? `${(-0.6 * (lines.length - 1)).toFixed(2)}em` : '1.2em'); + tspan.textContent = line; + textEl.appendChild(tspan); + }); +} + /** * Relabel branch feeders with the NAME of the voltage level at the OTHER end of * the branch (e.g. "MARSILLON 225kV") instead of pypowsybl's default raw IIDM * branch id (e.g. "relation_8423569-225"), which is hard to interpret on the * PyPSA grids (Issue 1). The backend (``build_feeder_labels``) resolves the - * far-end VL name + parallel-circuit index; here we just swap the matching - * ```` content. + * far-end VL name + parallel-circuit index; here we swap the matching + * ```` content, wrapping long labels so they don't occlude their + * neighbours, and tag each with ``data-feeder-nav`` (the far-end VL id) so a + * click can navigate to that VL's SLD. * * Idempotent: the original text is stashed in ``data-feeder-orig`` so a tab / * VL / preview switch restores cleanly, and highlight clones (which carry a @@ -61,9 +153,11 @@ export function applyFeederRelabels( // Restore any previous relabel first, then re-apply against the current SVG. container.querySelectorAll('[data-feeder-relabel]').forEach(el => { const orig = el.getAttribute('data-feeder-orig'); - if (orig !== null) el.textContent = orig; + if (orig !== null) el.textContent = orig; // replacing textContent drops any wrap s el.removeAttribute('data-feeder-relabel'); el.removeAttribute('data-feeder-orig'); + el.removeAttribute('data-feeder-nav'); + el.classList.remove('sld-feeder-navigable'); }); const entries = feederLabels ? Object.entries(feederLabels).filter(([, info]) => !!info && !!info.label) @@ -82,6 +176,55 @@ export function applyFeederRelabels( if (!textEl) continue; textEl.setAttribute('data-feeder-orig', textEl.textContent ?? ''); textEl.setAttribute('data-feeder-relabel', '1'); - textEl.textContent = label; + setFeederText(textEl, label); + if (info.other_vl) { + textEl.setAttribute('data-feeder-nav', info.other_vl); + textEl.classList.add('sld-feeder-navigable'); + } } } + +/** Feeder cells whose name label sits at the diagram extremity. */ +const FEEDER_CELL_SELECTOR = '.sld-extern-cell, .sld-intern-cell, .sld-shunt-cell'; +/** A flow value (``-14`` / ``16.8 MVAr``) rather than an equipment name. */ +const NUMERIC_LABEL_RE = /^[+-]?[\d.,\s]+(?:\s*[A-Za-z%°]+)?$/; + +/** + * Wrap every long feeder NAME label at a substation's extremities so the names + * stop overlapping — the generator / load / unmatched-branch names that + * :func:`applyFeederRelabels` does NOT rewrite (only far-end-named branches get + * relabelled). Targets the equipment-name ```` inside each feeder cell + * (excluding the numeric P/Q flow labels and the already-relabelled feeders, + * which wrap themselves) and rewrites it with the same centred multi-line + * ```` block used for relabels. + * + * Idempotent: the original text is stashed in ``data-feeder-wrap-orig`` and + * restored first, and highlight clones (removed on the next render) are skipped. + * Run AFTER :func:`applyFeederRelabels` so relabelled feeders are left alone. + */ +export function applyFeederLabelWrap( + container: HTMLElement, + activeSvg: string | null, +): void { + container.querySelectorAll('[data-feeder-wrap]').forEach(el => { + const orig = el.getAttribute('data-feeder-wrap-orig'); + if (orig !== null) el.textContent = orig; // replacing textContent drops any wrap s + el.removeAttribute('data-feeder-wrap'); + el.removeAttribute('data-feeder-wrap-orig'); + }); + if (!activeSvg) return; + + container.querySelectorAll(FEEDER_CELL_SELECTOR).forEach(cell => { + cell.querySelectorAll('text').forEach(textEl => { + if (textEl.closest('.sld-highlight-clone')) return; + if (textEl.hasAttribute('data-feeder-relabel')) return; // relabel wraps its own + if (textEl.closest('.sld-active-power, .sld-reactive-power')) return; // flow value + const raw = (textEl.textContent ?? '').trim(); + if (raw.length <= FEEDER_WRAP_CHARS || NUMERIC_LABEL_RE.test(raw)) return; + if (wrapFeederLabel(raw).length <= 1) return; + textEl.setAttribute('data-feeder-wrap-orig', textEl.textContent ?? ''); + textEl.setAttribute('data-feeder-wrap', '1'); + setFeederText(textEl, raw); + }); + }); +} diff --git a/frontend/src/utils/svg/metadataIndex.test.ts b/frontend/src/utils/svg/metadataIndex.test.ts index 577b61f..ddc882e 100644 --- a/frontend/src/utils/svg/metadataIndex.test.ts +++ b/frontend/src/utils/svg/metadataIndex.test.ts @@ -66,5 +66,31 @@ describe('buildMetadataIndex', () => { const idx = buildMetadataIndex({})!; expect(idx.nodesByEquipmentId.size).toBe(0); expect(idx.edgesByEquipmentId.size).toBe(0); + expect(idx.textNodesBySvgId?.size).toBe(0); + }); + + it('maps VL text-label nodes to their VL via the vlNode link', () => { + const idx = buildMetadataIndex({ + nodes: [{ equipmentId: 'VL_A', svgId: 'svg-a', x: 0, y: 0 }], + textNodes: [{ svgId: 'text-a', equipmentId: 'VL_A', vlNode: 'svg-a' }], + edges: [], + })!; + expect(idx.textNodesBySvgId?.get('text-a')?.equipmentId).toBe('VL_A'); + }); + + it('falls back to equipmentId when a text node has no vlNode link', () => { + const idx = buildMetadataIndex({ + nodes: [{ equipmentId: 'VL_A', svgId: 'svg-a', x: 0, y: 0 }], + textNodes: [{ svgId: 'text-a', equipmentId: 'VL_A' }], + })!; + expect(idx.textNodesBySvgId?.get('text-a')?.svgId).toBe('svg-a'); + }); + + it('skips text nodes that resolve to no known VL', () => { + const idx = buildMetadataIndex({ + nodes: [{ equipmentId: 'VL_A', svgId: 'svg-a', x: 0, y: 0 }], + textNodes: [{ svgId: 'text-x', equipmentId: 'VL_MISSING', vlNode: 'svg-missing' }], + })!; + expect(idx.textNodesBySvgId?.size).toBe(0); }); }); diff --git a/frontend/src/utils/svg/metadataIndex.ts b/frontend/src/utils/svg/metadataIndex.ts index cfaa08b..9c33043 100644 --- a/frontend/src/utils/svg/metadataIndex.ts +++ b/frontend/src/utils/svg/metadataIndex.ts @@ -6,6 +6,15 @@ import type { MetadataIndex, NodeMeta, EdgeMeta } from '../../types'; +/** Raw pypowsybl NAD `textNodes` entry — the VL name-box label metadata. */ +interface RawTextNode { + svgId?: string; + equipmentId?: string; + vlNode?: string; + node?: string; + connectedNode?: string; +} + /** * Build Map indices from pypowsybl metadata for O(1) lookups. * @@ -17,11 +26,13 @@ export const buildMetadataIndex = (metadata: unknown): MetadataIndex | null => { const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; const nodes: NodeMeta[] = (meta as { nodes?: NodeMeta[] }).nodes || []; const edges: EdgeMeta[] = (meta as { edges?: EdgeMeta[] }).edges || []; + const textNodes: RawTextNode[] = (meta as { textNodes?: RawTextNode[] }).textNodes || []; const nodesByEquipmentId = new Map(); const nodesBySvgId = new Map(); const edgesByEquipmentId = new Map(); const edgesByNode = new Map(); + const textNodesBySvgId = new Map(); nodes.forEach(n => { nodesByEquipmentId.set(n.equipmentId, n); @@ -36,5 +47,18 @@ export const buildMetadataIndex = (metadata: unknown): MetadataIndex | null => { edgesByNode.get(e.node2)!.push(e); }); - return { nodesByEquipmentId, nodesBySvgId, edgesByEquipmentId, edgesByNode }; + // pypowsybl NAD metadata carries a `textNodes` array for the VL name boxes, + // each linking back to its VL node (`vlNode` svgId) and equipment id. Map + // the label svgId → the VL's NodeMeta so a click on the name box resolves + // to the same VL the disk does. + textNodes.forEach(t => { + if (!t.svgId) return; + const vlNodeId = t.vlNode ?? t.node ?? t.connectedNode; + const vl = + (vlNodeId ? nodesBySvgId.get(vlNodeId) : undefined) ?? + (t.equipmentId ? nodesByEquipmentId.get(t.equipmentId) : undefined); + if (vl) textNodesBySvgId.set(t.svgId, vl); + }); + + return { nodesByEquipmentId, nodesBySvgId, edgesByEquipmentId, edgesByNode, textNodesBySvgId }; }; diff --git a/frontend/src/utils/svg/vlInteractions.test.ts b/frontend/src/utils/svg/vlInteractions.test.ts index d1cadc0..745e674 100644 --- a/frontend/src/utils/svg/vlInteractions.test.ts +++ b/frontend/src/utils/svg/vlInteractions.test.ts @@ -271,6 +271,52 @@ describe('attachVlInteractions', () => { expect(onSelect).toHaveBeenCalledWith('VL_225'); }); + it('resolves the disk under an occluding edge via the paint stack', () => { + const { container, disk } = makeContainer(); + // A branch drawn on top of the disk steals the direct hit-test. + const edge = document.createElementNS(SVG_NS, 'path'); + edge.id = 'edge-1'; + container.querySelector('svg')!.appendChild(edge); + + const onSelect = vi.fn(); + cleanups.push(attachVlInteractions(container, makeMetaIndex(), { onSelect })); + + // elementsFromPoint returns the occluding edge first, then the disk. + const orig = document.elementsFromPoint; + document.elementsFromPoint = vi.fn(() => [edge, disk]) as typeof document.elementsFromPoint; + + edge.dispatchEvent(mouse('mousedown')); + edge.dispatchEvent(mouse('click')); + vi.advanceTimersByTime(VL_SINGLE_CLICK_DELAY_MS); + expect(onSelect).toHaveBeenCalledWith('VL_400'); + document.elementsFromPoint = orig; + }); + + it('opens the SLD (not select) when the VL name box is clicked', () => { + const { container } = makeContainer(); + const fo = document.createElementNS(SVG_NS, 'foreignObject'); + fo.setAttribute('class', 'nad-text-nodes'); + const box = document.createElement('div'); + box.id = 'vl1-label'; + box.className = 'nad-label-box'; + box.textContent = 'PARIS 400kV'; + fo.appendChild(box); + container.querySelector('svg')!.appendChild(fo); + + const meta = makeMetaIndex(); + meta.textNodesBySvgId = new Map([['vl1-label', meta.nodesBySvgId.get('vl1-svg')!]]); + + const onSelect = vi.fn(); + const onOpenSld = vi.fn(); + cleanups.push(attachVlInteractions(container, meta, { onSelect, onOpenSld })); + + box.dispatchEvent(mouse('mousedown')); + box.dispatchEvent(mouse('click')); + vi.advanceTimersByTime(VL_SINGLE_CLICK_DELAY_MS); + expect(onOpenSld).toHaveBeenCalledWith('VL_400'); + expect(onSelect).not.toHaveBeenCalled(); + }); + it('does not fire a queued single-click after teardown', () => { const { container, disk } = makeContainer(); const onSelect = vi.fn(); diff --git a/frontend/src/utils/svg/vlInteractions.ts b/frontend/src/utils/svg/vlInteractions.ts index c9b4692..fabbc47 100644 --- a/frontend/src/utils/svg/vlInteractions.ts +++ b/frontend/src/utils/svg/vlInteractions.ts @@ -15,16 +15,27 @@ import { colors } from '../../styles/tokens'; * `nad-hide-vl-labels` class, toggled by the `🏷 VL` * button). When the labels are visible the name is already * drawn, so the tooltip stays out of the way. - * 2. Click — single-click selects the VL (drives the Inspect field / - * auto-zoom, exactly like typing it in the Inspect box). - * 3. Dbl-clk — double-click opens the VL's Single Line Diagram. + * 2. Click — single-click on the DISK selects the VL (drives the + * Inspect field / auto-zoom, exactly like typing it in the + * Inspect box); single-click on the VL NAME BOX opens its + * Single Line Diagram directly. + * 3. Dbl-clk — double-click on the disk (or name box) opens the VL's SLD. + * + * The disk is interactive across its WHOLE area, even where a branch is + * drawn on top of it: when the direct hit-test lands on an occluding edge + * (not the disk), we fall back to `document.elementsFromPoint` and pick the + * first VL disk / name box in the paint stack under the cursor. This runs + * only on discrete pointer events (never per frame), so the performance + * contract below is untouched. * * Performance contract (the whole point of the delegation design): * - A FIXED handful of listeners on the container, never one-per-node, * so a 5000-VL grid costs the same as a 5-VL grid to wire up. * - NO `mousemove` / per-frame work: the tooltip is positioned once on * `mouseover` and the cursor affordance is a static CSS rule - * (`.svg-container .nad-vl-nodes { cursor: pointer }` in App.css). + * (`.svg-container .nad-vl-nodes` / `.nad-label-box { cursor: pointer }` + * in App.css). The `elementsFromPoint` fallback fires only when the + * direct hit-test misses a VL, i.e. on an occluding edge or empty space. * - Pan/zoom gestures add `.svg-interacting`, which sets * `pointer-events: none` on every SVG child, so none of these * handlers resolve a node mid-gesture — fluidity is untouched. @@ -74,23 +85,50 @@ export const attachVlInteractions = ( if (!container || !metaIndex || metaIndex.nodesBySvgId.size === 0) return NOOP; const { nodesBySvgId } = metaIndex; + const textNodesBySvgId = metaIndex.textNodesBySvgId ?? new Map(); const { onSelect, onOpenSld, displayName } = handlers; - // Climb from the event target to the enclosing VL node group (the - // element whose id is a known node svgId), if any. - const resolveVl = (target: EventTarget | null): NodeMeta | null => { + // What a hit resolved to: the VL, and whether it landed on the name box + // (`viaText`) rather than the disk — the two get different click actions. + interface Resolved { node: NodeMeta; viaText: boolean; } + + // Climb from the event target to the enclosing VL node group (disk) or VL + // name box (`nad-label-box`, keyed by its text-node svgId), if any. + const resolveDirect = (target: EventTarget | null): Resolved | null => { let el = target as Element | null; while (el && el !== container) { const id = el.id; if (id) { const node = nodesBySvgId.get(id); - if (node) return node; + if (node) return { node, viaText: false }; + const textNode = textNodesBySvgId.get(id); + if (textNode) return { node: textNode, viaText: true }; } el = el.parentElement; } return null; }; + // Resolve a VL under the pointer even when a branch is drawn ON TOP of its + // disk: the direct hit-test then lands on the edge, so we walk the whole + // paint stack at (clientX, clientY) and take the first element that resolves + // to a VL. Only runs when the direct hit misses — never per frame. + const resolveDeep = ( + target: EventTarget | null, + clientX: number, + clientY: number, + ): Resolved | null => { + const direct = resolveDirect(target); + if (direct) return direct; + if (typeof document.elementsFromPoint !== 'function') return null; + for (const el of document.elementsFromPoint(clientX, clientY)) { + if (el === container || !container.contains(el)) continue; + const hit = resolveDirect(el); + if (hit) return hit; + } + return null; + }; + const tooltipText = (node: NodeMeta): string => { const id = node.equipmentId; const friendly = displayName ? displayName(id) : id; @@ -139,20 +177,22 @@ export const attachVlInteractions = ( }; const onOver = (evt: MouseEvent) => { - const node = resolveVl(evt.target); - if (!node) return; - hoveredSvgId = node.svgId; - // The name is already drawn when labels are visible — only - // surface the tooltip when the static labels are hidden. - if (container.classList.contains(HIDE_VL_LABELS_CLASS)) { - showTooltip(node, evt.clientX, evt.clientY); - } + // The name is already drawn when labels are visible — only surface the + // tooltip when the static labels are hidden. Gating the (slightly more + // expensive) `elementsFromPoint` fallback on that keeps hover cheap. + const labelsHidden = container.classList.contains(HIDE_VL_LABELS_CLASS); + const res = labelsHidden + ? resolveDeep(evt.target, evt.clientX, evt.clientY) + : resolveDirect(evt.target); + if (!res) return; + hoveredSvgId = res.node.svgId; + if (labelsHidden) showTooltip(res.node, evt.clientX, evt.clientY); }; const onOut = (evt: MouseEvent) => { // Ignore transitions that stay inside the same VL group. - const to = resolveVl(evt.relatedTarget); - if (to && to.svgId === hoveredSvgId) return; + const to = resolveDirect(evt.relatedTarget); + if (to && to.node.svgId === hoveredSvgId) return; hoveredSvgId = null; hideTooltip(); }; @@ -171,21 +211,30 @@ export const attachVlInteractions = ( let downX = 0; let downY = 0; let downVl: NodeMeta | null = null; + let downViaText = false; let clickTimer: ReturnType | null = null; const onDown = (evt: MouseEvent) => { downX = evt.clientX; downY = evt.clientY; - downVl = resolveVl(evt.target); + const res = resolveDeep(evt.target, evt.clientX, evt.clientY); + downVl = res ? res.node : null; + downViaText = res ? res.viaText : false; }; const onClick = (evt: MouseEvent) => { // A pan (pointer travelled) is not a selection. if (Math.hypot(evt.clientX - downX, evt.clientY - downY) > DRAG_THRESHOLD_PX) return; - // Second click of a double-click — let `dblclick` take over. - if (clickTimer !== null) return; const node = downVl; if (!node) return; + // Clicking the VL NAME BOX opens its SLD directly — no double-click + // window to guard (the box carries no competing single-click action). + if (downViaText) { + onOpenSld?.(node.equipmentId); + return; + } + // Second click of a double-click — let `dblclick` take over. + if (clickTimer !== null) return; const vlId = node.equipmentId; clickTimer = setTimeout(() => { clickTimer = null;