Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
# ------------------------------------------------------------------
Expand Down
43 changes: 40 additions & 3 deletions docs/features/sld-diagram-feeder-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tspan>`
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 `<text>` 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

Expand Down Expand Up @@ -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`.
37 changes: 32 additions & 5 deletions docs/features/vl-disk-interactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,55 @@ 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 `<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 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
Expand Down
13 changes: 10 additions & 3 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
<text>, with the label recoloured on top. */
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2024,6 +2032,7 @@ function App() {
onSldSwitchFocus={sldTopologyEdit.setFocusedSwitch}
onSldSwitchRemove={sldTopologyEdit.removeSwitch}
onSldSwitchRemoveMany={sldTopologyEdit.removeSwitches}
onSldNavigateToVl={handleSldNavigateToVl}
voltageLevels={voltageLevels}
onVlOpen={handleVlOpen}
onOverflowPinPreview={handlePinPreview}
Expand Down
37 changes: 35 additions & 2 deletions frontend/src/components/SldOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SldOverlay {...defaultProps} vlOverlay={overlay} />);
expect(container.querySelector('#lbl_marsil')?.textContent).toBe('LANNEMEZAN 225kV 2');
const lbl = container.querySelector('#lbl_marsil')!;
// Long labels wrap onto <tspan> 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(<SldOverlay {...defaultProps} vlOverlay={overlay} />);
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(
<SldOverlay {...defaultProps} vlOverlay={overlay} onNavigateToVl={onNavigateToVl} />,
);
container.querySelector<SVGTextElement>('#lbl_marsil')!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
expect(onNavigateToVl).toHaveBeenCalledWith('VL_MARSIL');
});

it('leaves the id untouched when the label is null', () => {
Expand Down
28 changes: 25 additions & 3 deletions frontend/src/components/SldOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<SldOverlayProps> = ({
Expand Down Expand Up @@ -121,6 +130,7 @@ const SldOverlay: React.FC<SldOverlayProps> = ({
onSwitchFocus,
onSwitchRemove,
onSwitchRemoveMany,
onNavigateToVl,
}) => {
const overlayBodyRef = useRef<HTMLDivElement>(null);
// True once a press-drag has moved the pointer beyond a small slop
Expand Down Expand Up @@ -1024,13 +1034,21 @@ const SldOverlay: React.FC<SldOverlayProps> = ({
}
});

// 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
Expand Down Expand Up @@ -1114,6 +1132,10 @@ const SldOverlay: React.FC<SldOverlayProps> = ({
// 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]');
Expand Down
Loading
Loading