Co-Study4Grid / frontend /src /components /ActionOverviewDiagram.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
53.3 kB
// 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 React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ActionDetail, ActionOverviewFilters, DiagramData, MetadataIndex, UnsimulatedActionScoreInfo, ViewBox } from '../types';
import { DEFAULT_ACTION_OVERVIEW_FILTERS, matchesActionTypeFilter } from '../utils/actionTypes';
import { colors } from '../styles/tokens';
import {
actionPassesOverviewFilter,
applyActionOverviewHighlights,
applyActionOverviewPins,
buildActionOverviewPins,
buildCombinedActionPins,
buildUnsimulatedActionPins,
computeActionOverviewFitRect,
computeEquipmentFitRect,
invalidateIdMapCache,
rescaleActionOverviewPins,
} from '../utils/svgUtils';
import { usePanZoom } from '../hooks/usePanZoom';
import ActionCardPopover from './ActionCardPopover';
import InspectSearchField from './InspectSearchField';
import { filterInspectables } from '../utils/inspectables';
import { interactionLogger } from '../utils/interactionLogger';
import {
POPOVER_WIDTH,
POPOVER_MAX_HEIGHT,
decidePopoverPlacement,
computePopoverStyle,
} from '../utils/popoverPlacement';
/** Measure a named code section and log it to the console + Performance timeline. */
const perfMeasure = (name: string, startMark: string, endMark: string) => {
const entry = performance.measure(name, startMark, endMark);
console.log(`[PERF] ${name}: ${entry.duration.toFixed(2)}ms`);
};
/**
* Action-overview diagram.
*
* When the Remedial Action tab is opened without any action card
* selected, this view is rendered inside that tab. It shows the
* N-1 NAD as an immutable background and overlays one Google-Maps
* style pin per prioritised action, anchored on the asset the
* corresponding action card highlights in its badges.
*
* Interactions supported here:
* - auto-zoom on open to the bounding rectangle of
* (contingency + overloads + all pins), with a small margin
* - wheel-zoom + drag-pan via the shared `usePanZoom` hook
* - local zoom-in / zoom-out / reset buttons
* - asset-focus inspect search that pans/zooms to a named
* equipment (line or voltage-level) using the same metadata
* index as the rest of the NAD highlights
* - pin click → delegate to the existing action-select flow,
* which folds this view away and reveals the detailed
* action-variant diagram with all its existing interactions
*/
interface ActionOverviewDiagramProps {
n1Diagram: DiagramData | null;
n1MetaIndex: MetadataIndex | null;
actions: Record<string, ActionDetail> | undefined;
monitoringFactor: number;
/**
* Called with a pin id on a DOUBLE click — this is the
* "activate the action drill-down view" path. The parent
* wires it to the existing action-select flow so the action
* network diagram replaces the overview in the tab.
*/
onActionSelect: (actionId: string) => void;
/** Toggle the favourite status of an action (popover action). */
onActionFavorite?: (actionId: string) => void;
/** Reject an action (popover action). */
onActionReject?: (actionId: string) => void;
/** Selected-action set — pins are highlighted with a gold star. */
selectedActionIds?: Set<string>;
/** Rejected-action set — pins are dimmed with a red cross. */
rejectedActionIds?: Set<string>;
/**
* Called when a pin is single-clicked (preview). The parent can
* use this to scroll the sidebar action feed to the matching card.
*/
onPinPreview?: (actionId: string) => void;
/** Current contingency (selected branch) — included in the auto-fit rectangle. */
contingency: string | null;
/** Overloaded lines in the N-1 state — included in the auto-fit rectangle. */
overloadedLines: readonly string[];
/** Searchable equipment ids for the inspect field. */
inspectableItems: readonly string[];
visible: boolean;
/**
* Called whenever the overview's `usePanZoom` instance changes
* (which happens on every viewBox update, since usePanZoom
* returns a new object identity via useMemo). The parent stores
* the instance in React **state** so the tied-tabs sync hook
* sees the new viewBox in its dependency list and can propagate
* the change bidirectionally.
*
* A ref-based approach doesn't work here: writing to a ref
* doesn't trigger a re-render of App, so the sync hook's
* `actionVb` dep stays stale and detached→main mirroring
* never fires.
*/
onPzChange?: (pz: ReturnType<typeof usePanZoom>) => void;
/** Whether the action tab is currently tied (zoom-sync with main window). */
isTied?: boolean;
/** Toggle the tied state for the action tab. */
onToggleTie?: () => void;
/** Whether the action tab is currently detached (controls Tie button visibility). */
isDetached?: boolean;
/** Resolve an element/VL ID to its human-readable display name. */
displayName?: (id: string) => string;
/**
* Active category + threshold filters shared with the ActionFeed.
* When omitted all categories are enabled and the threshold is 1.5.
*/
filters?: ActionOverviewFilters;
/** Update the filter state (owned by App.tsx). */
onFiltersChange?: (next: ActionOverviewFilters) => void;
/**
* Ids of scored-but-not-simulated actions the pin layer can render
* as dimmed, dashed pins when {@link filters}.showUnsimulated is
* enabled. Supplied by App.tsx from `result.action_scores`.
*/
unsimulatedActionIds?: readonly string[];
/**
* Per-id score metadata (type, score, MW/tap start, rank in
* category, max score in category) — when provided, enriches the
* pin tooltip so the operator can triage un-simulated actions
* without leaving the overview.
*/
unsimulatedActionInfo?: Readonly<Record<string, UnsimulatedActionScoreInfo>>;
/**
* Kick off a manual simulation for an unsimulated action when its
* pin is double-clicked. Expected to call the same code path as
* the Manual Selection dropdown.
*/
onSimulateUnsimulatedAction?: (actionId: string) => void;
}
const ZOOM_STEP_IN = 0.8;
const ZOOM_STEP_OUT = 1.25;
const scaleViewBox = (vb: ViewBox, factor: number): ViewBox => ({
x: vb.x + (vb.w * (1 - factor)) / 2,
y: vb.y + (vb.h * (1 - factor)) / 2,
w: vb.w * factor,
h: vb.h * factor,
});
const ActionOverviewDiagram: React.FC<ActionOverviewDiagramProps> = ({
n1Diagram,
n1MetaIndex,
actions,
monitoringFactor,
onActionSelect,
onActionFavorite,
onActionReject,
selectedActionIds,
rejectedActionIds,
onPinPreview,
contingency,
overloadedLines,
inspectableItems,
visible,
onPzChange,
isTied,
onToggleTie,
isDetached,
displayName,
filters,
onFiltersChange,
unsimulatedActionIds,
unsimulatedActionInfo,
onSimulateUnsimulatedAction,
}) => {
// Normalize against DEFAULT_ACTION_OVERVIEW_FILTERS so legacy call sites that
// predate a given field (e.g. `actionType`) don't crash the
// matcher with `undefined`. Later shipped-filters win over the
// defaults for the fields they set.
const activeFilters = useMemo<ActionOverviewFilters>(() => {
if (!filters) return DEFAULT_ACTION_OVERVIEW_FILTERS;
return {
...DEFAULT_ACTION_OVERVIEW_FILTERS,
...filters,
actionType: filters.actionType ?? DEFAULT_ACTION_OVERVIEW_FILTERS.actionType,
};
}, [filters]);
const containerRef = useRef<HTMLDivElement | null>(null);
// Pull the svg string into a local so the React Compiler can
// see that both the injection effect and the initialViewBox memo
// depend on the same primitive, not on the full `n1Diagram`
// object identity.
const svgSource = n1Diagram?.svg ?? null;
// `svgReady` is derived, not stateful: svg injection happens
// synchronously in the layout effect below (which is declared
// BEFORE usePanZoom so its layout effect runs first, i.e. the
// DOM is already populated by the time usePanZoom caches its
// svgElRef). Once the source is in hand the svg IS in the DOM.
const svgReady = svgSource != null;
// Pre-parse the SVG string into an SVGSVGElement so the layout
// effect below can inject it with `replaceChildren()` — zero
// extra parse, matching the MemoizedSvgContainer optimisation
// used by the N and N-1 tabs. The dim-layer wrapping is done
// here as well (moving children into a `<g>` with reduced
// opacity) so the DOM mutation happens on the detached element
// before it enters the live document — no forced layout.
//
// When `svgSource` is already a pre-parsed SVGSVGElement — the
// case when the N-1 tab was populated via the svgPatch
// DOM-recycling path (see docs/performance/history/svg-dom-recycling.md)
// — skip the string round-trip entirely and clone the element.
/* eslint-disable react-hooks/purity -- performance.now() is side-effect-free in practice (timing only) */
const preparedSvg = useMemo<SVGSVGElement | null>(() => {
if (!svgSource) return null;
const start = performance.now();
let svg: SVGSVGElement;
if (typeof svgSource === 'string') {
const parser = new DOMParser();
const doc = parser.parseFromString(svgSource, 'image/svg+xml');
svg = doc.documentElement as unknown as SVGSVGElement;
} else {
// Clone so we don't detach the element from the N-1 tab.
svg = svgSource.cloneNode(true) as SVGSVGElement;
}
if (!svg || svg.nodeName !== 'svg') return null;
// Style the root so it fills the container; viewBox is the
// only thing usePanZoom manipulates.
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
// Guard: jsdom's DOMParser returns XML documents whose
// elements lack a `.style` property; real browsers always
// have it.
if (svg.style) {
svg.style.width = '100%';
svg.style.height = '100%';
}
// Dim the network background by inserting a semi-transparent
// white <rect> that covers the entire viewBox, placed AFTER
// all the original NAD content. This dims everything behind
// it without applying `opacity` to any child element — which
// avoids creating stacking contexts (CSS per-child opacity)
// or SVG transparency groups (SVG opacity attribute on <g>),
// both of which force Chrome's Layerize step to composite
// each child individually (~25-31s on large grids).
//
// Visual stack (back to front):
// 1. highlight layer (inserted later by applyActionOverviewHighlights
// at SVG start — behind NAD, matching N-1 tab behaviour)
// 2. original NAD content (full opacity)
// 3. white rect overlay (opacity 0.65 → dims NAD + highlights)
// 4. pin layer (inserted later by applyActionOverviewPins)
const SVG_NS = 'http://www.w3.org/2000/svg';
const vb = svg.getAttribute('viewBox');
if (vb) {
const parts = vb.split(/[\s,]+/).map(Number);
if (parts.length === 4) {
const dimRect = document.createElementNS(SVG_NS, 'rect');
dimRect.setAttribute('class', 'nad-overview-dim-rect');
// Extend 10% beyond viewBox on each side to cover any
// pan-zoom overshoot — the rect is cheap, and gaps at
// the edges look ugly.
const margin = Math.max(parts[2], parts[3]) * 0.1;
dimRect.setAttribute('x', String(parts[0] - margin));
dimRect.setAttribute('y', String(parts[1] - margin));
dimRect.setAttribute('width', String(parts[2] + 2 * margin));
dimRect.setAttribute('height', String(parts[3] + 2 * margin));
dimRect.setAttribute('fill', 'white');
dimRect.setAttribute('opacity', '0.65');
// pointer-events: none so clicks pass through to
// highlights/pins above OR the NAD content below.
dimRect.setAttribute('pointer-events', 'none');
svg.appendChild(dimRect);
}
}
// Mark the SVG so highlight/pin insertion code knows the
// dimming rect is present.
svg.classList.add('nad-overview-dimmed');
console.log(`[SVG] Action overview pre-parse took ${(performance.now() - start).toFixed(2)}ms`);
return svg;
}, [svgSource]);
/* eslint-enable react-hooks/purity */
// Three-pass pin build so combined-action constituents are kept
// visible (dimmed) even when they would individually be filtered
// out by the category/threshold header:
// 1. Build every unitary pin unfiltered so combined pins have
// endpoints to anchor on.
// 2. Build combined pins from the unfiltered set, then drop the
// ones that fail the overview filter themselves.
// 3. Compute the set of unitary ids referenced by any surviving
// combined pin — those are "protected" from being hidden.
// 4. Re-filter the unitary pin list: passing pins go through
// as-is, protected-but-failing pins come through with a
// `dimmedByFilter` flag, everything else is dropped.
// Helpers that combine the severity/threshold filter AND the
// single-select action-type chip. When `actionType` is 'all' the
// chip check is a no-op.
const passesAll = useCallback((id: string, det: ActionDetail) => {
if (!actionPassesOverviewFilter(
det, monitoringFactor,
activeFilters.categories, activeFilters.threshold,
)) return false;
return matchesActionTypeFilter(activeFilters.actionType, id, det.description_unitaire, null);
}, [monitoringFactor, activeFilters.categories, activeFilters.threshold, activeFilters.actionType]);
const pins = useMemo(() => {
if (!n1MetaIndex || !actions) return [];
performance.mark('aod:buildPins:start');
const allUnitary = buildActionOverviewPins(actions, n1MetaIndex, monitoringFactor);
const allCombined = buildCombinedActionPins(actions, allUnitary, monitoringFactor);
// A combined pin is considered "in scope" for the type
// filter if EITHER constituent matches — combined actions are
// inherently multi-type and hiding a pair because one side
// doesn't match the chip would surprise the operator.
const combinedPassesTypeFilter = (cp: { action1Id: string; action2Id: string }): boolean => {
if (activeFilters.actionType === 'all') return true;
const d1 = actions[cp.action1Id];
const d2 = actions[cp.action2Id];
return (
(d1 ? matchesActionTypeFilter(activeFilters.actionType, cp.action1Id, d1.description_unitaire, null) : false)
|| (d2 ? matchesActionTypeFilter(activeFilters.actionType, cp.action2Id, d2.description_unitaire, null) : false)
);
};
const protectedIds = new Set<string>();
for (const cp of allCombined) {
const det = actions[cp.pairId];
if (!det) continue;
if (actionPassesOverviewFilter(
det, monitoringFactor,
activeFilters.categories, activeFilters.threshold,
) && combinedPassesTypeFilter(cp)) {
protectedIds.add(cp.action1Id);
protectedIds.add(cp.action2Id);
}
}
const result: typeof allUnitary = [];
for (const p of allUnitary) {
const det = actions[p.id];
const passes = det ? passesAll(p.id, det) : true;
if (activeFilters.showCombinedOnly) {
// Combined-only mode: emit unitary pins ONLY when they
// are constituents of a passing combined pin. Everything
// else is dropped so the operator's focus stays on the
// pair glyphs. Constituents come through dimmed because
// they are context for the pair, not first-class actions.
if (protectedIds.has(p.id)) {
result.push({ ...p, dimmedByFilter: true });
}
} else if (passes) {
result.push(p);
} else if (protectedIds.has(p.id)) {
result.push({ ...p, dimmedByFilter: true });
}
}
performance.mark('aod:buildPins:end');
perfMeasure('aod:buildPins', 'aod:buildPins:start', 'aod:buildPins:end');
return result;
}, [n1MetaIndex, actions, monitoringFactor, activeFilters.categories, activeFilters.threshold, activeFilters.actionType, activeFilters.showCombinedOnly, passesAll]);
const combinedPins = useMemo(() => {
if (!actions || pins.length === 0) return [];
// Combined pins themselves are filtered by the overview
// header: a combined action whose severity/threshold fails
// the filter is dropped. Unfiltered constituents on the
// kept combined pins stay visible via the dimmed branch in
// the `pins` memo above.
return buildCombinedActionPins(actions, pins, monitoringFactor).filter(cp => {
const det = actions[cp.pairId];
if (!det) return true;
if (!actionPassesOverviewFilter(
det, monitoringFactor,
activeFilters.categories, activeFilters.threshold,
)) return false;
if (activeFilters.actionType === 'all') return true;
const d1 = actions[cp.action1Id];
const d2 = actions[cp.action2Id];
return (
(d1 ? matchesActionTypeFilter(activeFilters.actionType, cp.action1Id, d1.description_unitaire, null) : false)
|| (d2 ? matchesActionTypeFilter(activeFilters.actionType, cp.action2Id, d2.description_unitaire, null) : false)
);
});
}, [actions, pins, monitoringFactor, activeFilters.categories, activeFilters.threshold, activeFilters.actionType]);
const unsimulatedPins = useMemo(() => {
if (!activeFilters.showUnsimulated) return [];
// Un-simulated actions are by definition NOT part of any
// computed pair — drop them entirely when "combined only" is
// active so the overview stays focused on the pair glyphs.
if (activeFilters.showCombinedOnly) return [];
if (!n1MetaIndex || !unsimulatedActionIds || unsimulatedActionIds.length === 0) return [];
const simulatedIds = new Set(Object.keys(actions ?? {}));
// Drop ids that don't match the active type chip. We rely
// on the score-info `type` string when available; fall back
// to the id-based heuristics in `classifyActionType`.
const filteredIds = activeFilters.actionType === 'all'
? unsimulatedActionIds
: unsimulatedActionIds.filter(id => {
const scoreType = unsimulatedActionInfo?.[id]?.type ?? null;
return matchesActionTypeFilter(activeFilters.actionType, id, null, scoreType);
});
return buildUnsimulatedActionPins(filteredIds, simulatedIds, n1MetaIndex, unsimulatedActionInfo);
}, [activeFilters.showUnsimulated, activeFilters.showCombinedOnly, activeFilters.actionType, n1MetaIndex, unsimulatedActionIds, actions, unsimulatedActionInfo]);
// Deterministic auto-fit rectangle derived from the bounding
// box of contingency + overloads + pins. Recomputed whenever any
// of those inputs changes — the new object identity propagates
// into `usePanZoom` via its `initialViewBox` effect, which
// re-applies the fit automatically.
const fitRect = useMemo<ViewBox | null>(() => {
if (!n1MetaIndex) return null;
const allPinPositions = [...pins, ...combinedPins, ...unsimulatedPins];
return computeActionOverviewFitRect(n1MetaIndex, contingency, overloadedLines, allPinPositions);
}, [n1MetaIndex, contingency, overloadedLines, pins, combinedPins, unsimulatedPins]);
// Fall back to the original NAD viewBox if we couldn't build a
// fit rectangle (e.g. no analysis has run yet) — that way the
// user still sees the full network the moment the tab opens.
const initialViewBox = useMemo<ViewBox | null>(() => {
if (fitRect) return fitRect;
if (!svgSource) return null;
const vbAttr = typeof svgSource === 'string'
? (svgSource.match(/viewBox=["']([^"']+)["']/)?.[1] ?? null)
: svgSource.getAttribute('viewBox');
if (!vbAttr) return null;
const parts = vbAttr.split(/\s+|,/).map(parseFloat);
if (parts.length !== 4 || parts.some(p => !Number.isFinite(p))) return null;
return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
}, [fitRect, svgSource]);
// Inject the pre-parsed SVGSVGElement into the container using
// `replaceChildren()` — zero extra parse, matching the
// MemoizedSvgContainer optimisation used by the N/N-1 tabs.
//
// DECLARED BEFORE `usePanZoom` so React runs this layout
// effect first and the hook can cache its svgElRef against the
// freshly-injected element.
//
// The dim-layer wrapping has already been done in `preparedSvg`
// (off-DOM), so this effect only moves the ready element in.
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
if (!preparedSvg) {
container.replaceChildren();
return;
}
const start = performance.now();
container.replaceChildren(preparedSvg);
// The id-map cache from a previous SVG is now stale.
invalidateIdMapCache(container);
console.log(`[SVG] Action overview replaceChildren took ${(performance.now() - start).toFixed(2)}ms`);
}, [preparedSvg]);
// The hook handles wheel-zoom and drag-pan on the container.
// `active` gates the event listeners so an invisible overview
// doesn't steal wheel events from other tabs.
const pz = usePanZoom(containerRef, initialViewBox, visible && svgReady);
// Notify the parent whenever the PZ instance changes. Because
// `usePanZoom` returns a new object on every viewBox update
// (line 313 of usePanZoom.ts: `useMemo([viewBox, setViewBox])`),
// this fires on every pan/zoom — which is exactly what the
// tied-tabs sync hook needs to detect a change in the detached
// popup and mirror it to the main window.
useEffect(() => {
onPzChange?.(pz);
}, [pz, onPzChange]);
// Asset-focus "consume once" tracking — declared up here so
// `handleReset` (defined just below) can clear it.
//
// Mirrors the pattern used by `useDiagrams` for the main
// inspect field (see `lastZoomState` and the auto-zoom
// effect around useDiagrams.ts:787): only apply the zoom
// when the resolved query TRANSITIONS to a new value. If we
// re-ran the zoom on every dependency change (e.g. every
// time `pz.viewBox` updates on wheel-zoom), the user would
// get yanked back onto the asset the moment they try to
// zoom out — the "sticking" bug.
const lastFocusedRef = useRef<string | null>(null);
// ----- Click popover -----
// Single-click on a pin opens a floating ActionCard popover
// anchored next to the pin in screen coordinates; double-click
// activates the full action drill-down view (see
// `handlePinDoubleClick`) and closes the popover.
//
// Placement is computed at click time from the pin's screen
// position relative to the viewport. The popover may sit
// ABOVE or BELOW the pin (whichever side has more room) and
// is horizontally aligned LEFT-of, CENTERED-on, or RIGHT-of
// the pin to avoid clipping at the visualisation panel
// edges. See `decidePopoverPlacement` for the rules.
const [popoverPin, setPopoverPin] = useState<{
id: string;
screenX: number;
screenY: number;
placeAbove: boolean;
horizontalAlign: 'start' | 'center' | 'end';
} | null>(null);
// Viewport captured at click time so `computePopoverStyle` renders
// the popover relative to the popup window that was actually
// clicked — otherwise detached-window clicks would fall back to
// the main window's dimensions (see popoverPlacement.defaultViewport).
const [popoverViewport, setPopoverViewport] = useState<{ width: number; height: number } | null>(null);
const handlePinClick = useCallback((actionId: string, screenPos: { x: number; y: number }) => {
interactionLogger.record('overview_pin_clicked', { action_id: actionId });
performance.mark('aod:pinClick:start');
// When the overview is detached into a secondary window, the
// container lives in that window's document — use its viewport
// (not main-window `window.innerWidth/innerHeight`) so the
// above/below placement reflects where the pin actually sits
// on the user's screen. Falls back to the default (main window)
// viewport when the ref isn't attached yet.
const ownerWin = containerRef.current?.ownerDocument?.defaultView ?? null;
const viewport = ownerWin
? { width: ownerWin.innerWidth, height: ownerWin.innerHeight }
: undefined;
const placement = decidePopoverPlacement(screenPos.x, screenPos.y, viewport);
setPopoverPin({
id: actionId,
screenX: screenPos.x,
screenY: screenPos.y,
...placement,
});
setPopoverViewport(viewport ?? null);
// Scroll the sidebar action feed to the matching card so the
// operator can see both the popover on the diagram and the full
// card details side-by-side.
onPinPreview?.(actionId);
// Measure will complete after React commits the state update.
requestAnimationFrame(() => {
performance.mark('aod:pinClick:end');
perfMeasure('aod:pinClick', 'aod:pinClick:start', 'aod:pinClick:end');
});
}, [onPinPreview]);
const handlePinDoubleClick = useCallback((actionId: string) => {
interactionLogger.record('overview_pin_double_clicked', { action_id: actionId });
// Make sure no stale popover is left behind on the way
// into the drill-down view.
setPopoverPin(null);
onActionSelect(actionId);
}, [onActionSelect]);
const handleUnsimulatedPinDoubleClick = useCallback((actionId: string) => {
interactionLogger.record('overview_unsimulated_pin_simulated', { action_id: actionId });
setPopoverPin(null);
if (onSimulateUnsimulatedAction) {
onSimulateUnsimulatedAction(actionId);
} else {
// Fall back on the default select path so the operator is
// never stranded when the parent hasn't wired the simulate
// callback yet.
onActionSelect(actionId);
}
}, [onSimulateUnsimulatedAction, onActionSelect]);
// (Re)apply contingency + overload highlights whenever the
// contingency or the N-1 overload set changes. Mirrors what
// the N-1 tab shows so the operator keeps the same situational
// awareness on the overview. The helper inserts a dedicated
// `.nad-overview-highlight-layer` at the START of the SVG so
// the halos render behind the NAD content, matching the N-1
// tab's getBackgroundLayer() pattern.
useEffect(() => {
if (!svgReady) return;
const container = containerRef.current;
if (!container) return;
performance.mark('aod:highlights:start');
applyActionOverviewHighlights(container, n1MetaIndex ?? null, contingency, overloadedLines);
performance.mark('aod:highlights:end');
perfMeasure('aod:highlights', 'aod:highlights:start', 'aod:highlights:end');
}, [svgReady, preparedSvg, n1MetaIndex, contingency, overloadedLines]);
// (Re)apply pins whenever the source SVG or pin list changes.
// Pins are appended to the SVG itself (not to the container
// div) so the existing viewBox-based pan/zoom naturally
// transforms them with the rest of the diagram.
useEffect(() => {
if (!svgReady) return;
const container = containerRef.current;
if (!container) return;
performance.mark('aod:applyPins:start');
applyActionOverviewPins(container, pins, handlePinClick, handlePinDoubleClick, {
selectedActionIds,
rejectedActionIds,
combinedPins,
unsimulatedPins,
onUnsimulatedPinDoubleClick: handleUnsimulatedPinDoubleClick,
});
performance.mark('aod:applyPins:end');
perfMeasure('aod:applyPins', 'aod:applyPins:start', 'aod:applyPins:end');
}, [pins, handlePinClick, handlePinDoubleClick, svgReady, preparedSvg, selectedActionIds, rejectedActionIds, combinedPins, unsimulatedPins, handleUnsimulatedPinDoubleClick]);
// Screen-constant pin compensation.
//
// The pin glyph is drawn in SVG-space at a base radius equal to
// the voltage-level circle radius (so at normal zoom they match
// the VL glyphs). As the operator zooms OUT, the SVG units map
// to fewer screen pixels and the pins would become tiny dots on
// large grids. To fight that we upscale the pin body in
// SVG-space whenever the viewBox changes — same spirit as the
// non-scaling-stroke trick used on overload / contingency
// circle halos in App.css.
//
// A MutationObserver on the svg's `viewBox` attribute catches
// every update: the wheel-zoom path in usePanZoom writes the
// DOM directly (bypassing React state for perf), so a plain
// `pz.viewBox` useEffect dep would lag behind the live drag.
//
// The rescale call is rAF-throttled because the wheel-zoom and
// drag-pan paths in usePanZoom mutate the viewBox on every
// animation frame (and sometimes more than once per frame on
// wheel bursts). Without throttling, large grids would call
// `getScreenCTM()` — a forced layout — many times per frame
// and freeze the page (the "Page ne répondant pas" we saw in
// the field). One scheduled rescale per frame is enough.
useEffect(() => {
if (!svgReady || !visible) return;
const container = containerRef.current;
if (!container) return;
const svg = container.querySelector('svg');
if (!svg) return;
// Initial compensation — pins are already mounted by the
// applyActionOverviewPins effect above; make sure they
// come up at the right size on the first paint.
rescaleActionOverviewPins(container);
let rafId: number | null = null;
const scheduleRescale = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
rescaleActionOverviewPins(container);
});
};
const observer = new MutationObserver(scheduleRescale);
observer.observe(svg, { attributes: true, attributeFilter: ['viewBox'] });
return () => {
observer.disconnect();
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [svgReady, visible, preparedSvg, pins]);
// When the view becomes visible for the first time (or again
// after a round-trip through an action drill-down), re-assert
// the fit rectangle. Without this, a card selection + deselect
// would leave the overview on whatever viewBox the user had
// last panned to — surprising, because the tab is nominally
// "opening fresh".
const wasVisibleRef = useRef(false);
useEffect(() => {
if (visible && !wasVisibleRef.current) {
interactionLogger.record('overview_shown', { has_pins: pins.length > 0, pin_count: pins.length });
if (initialViewBox) pz.setViewBox(initialViewBox);
} else if (!visible && wasVisibleRef.current) {
interactionLogger.record('overview_hidden');
}
wasVisibleRef.current = visible;
}, [visible, initialViewBox, pz, pins.length]);
const handleZoomIn = useCallback(() => {
if (!pz.viewBox) return;
interactionLogger.record('overview_zoom_in');
pz.setViewBox(scaleViewBox(pz.viewBox, ZOOM_STEP_IN));
}, [pz]);
const handleZoomOut = useCallback(() => {
if (!pz.viewBox) return;
interactionLogger.record('overview_zoom_out');
pz.setViewBox(scaleViewBox(pz.viewBox, ZOOM_STEP_OUT));
}, [pz]);
const handleReset = useCallback(() => {
interactionLogger.record('overview_zoom_fit');
if (initialViewBox) pz.setViewBox(initialViewBox);
// Clearing here ensures that re-typing the same asset id
// after a reset will focus it again — without this the
// consume-once guard would treat the effect re-run as
// "nothing changed" and silently skip the zoom.
lastFocusedRef.current = null;
}, [initialViewBox, pz]);
// ----- Inspect / asset-focus search -----
// Kept deliberately local so the overview manages its own
// focus without plumbing through the detached-tab /
// tied-tab infrastructure of the main-window inspect field. The
// input + dropdown UI is the shared `InspectSearchField` so this
// surface searches by displayed name exactly like the N / N-1 tabs.
const [inspectQuery, setInspectQuery] = useState('');
const filteredInspectables = useMemo(
() => filterInspectables(inspectableItems, inspectQuery, displayName, 20),
[inspectQuery, inspectableItems, displayName],
);
const exactInspectMatch = useMemo(() => {
if (!inspectQuery) return null;
const q = inspectQuery.toUpperCase();
return inspectableItems.find(item => item.toUpperCase() === q) ?? null;
}, [inspectQuery, inspectableItems]);
useEffect(() => {
if (!svgReady || !visible || !n1MetaIndex) return;
// Nothing changed — bail out. This is the guard that
// suppresses re-zooming when the effect re-runs because
// `pz.viewBox` (and therefore the `pz` object) changed
// during a user-initiated pan/zoom.
if (exactInspectMatch === lastFocusedRef.current) return;
if (exactInspectMatch) {
// Consume the new query — zoom onto the asset.
interactionLogger.record('overview_inspect_changed', { query: exactInspectMatch, action: 'focus' });
const target = computeEquipmentFitRect(n1MetaIndex, exactInspectMatch);
if (target) pz.setViewBox(target);
} else if (lastFocusedRef.current && initialViewBox) {
interactionLogger.record('overview_inspect_changed', { query: '', action: 'cleared' });
// Query cleared (or no longer an exact match): go
// back to the auto-fit rectangle so the user sees
// the whole network-plus-pins again.
pz.setViewBox(initialViewBox);
}
lastFocusedRef.current = exactInspectMatch;
}, [exactInspectMatch, svgReady, visible, n1MetaIndex, pz, initialViewBox]);
// Imperative zoom helper used by the suggestions dropdown —
// committing the query through state triggers the effect
// above, but the dropdown also commits the text value so
// the click feels instantaneous without waiting for the
// next effect tick.
const focusEquipment = useCallback((equipmentId: string) => {
const target = computeEquipmentFitRect(n1MetaIndex, equipmentId);
if (target) pz.setViewBox(target);
lastFocusedRef.current = equipmentId;
}, [n1MetaIndex, pz]);
const hasAnyAction = !!actions && Object.keys(actions).length > 0;
const noBackground = !n1Diagram?.svg;
// ----- Popover dismissal -----
// All close paths (Escape, outside-click, ✕ button, drill-down
// activation) route through this helper so the log entry is
// emitted exactly once per dismiss.
const closePopover = useCallback((reason: string) => {
interactionLogger.record('overview_popover_closed', { reason });
setPopoverPin(null);
setPopoverViewport(null);
}, []);
const popoverRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!popoverPin) return;
const onDocMouseDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (popoverRef.current && target && popoverRef.current.contains(target)) return;
if (target instanceof Element && target.closest('.nad-action-overview-pin')) return;
closePopover('outside_click');
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closePopover('escape');
};
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onKey);
};
}, [popoverPin, closePopover]);
// ----- Filter controls (category toggles + threshold slider +
// un-simulated-pin toggle). These mutate shared filter
// state owned by App.tsx so the ActionFeed follows the
// same visibility rules.
const pushFilters = useCallback((next: ActionOverviewFilters) => {
onFiltersChange?.(next);
}, [onFiltersChange]);
// ``setThreshold`` moved to ActionFilterRings — the sidebar
// strip now owns the Max-loading control so the overview header
// stays single-row (avoids two-line wrap on narrow viewports).
const toggleUnsimulated = useCallback(() => {
const next = !activeFilters.showUnsimulated;
interactionLogger.record('overview_unsimulated_toggled', { enabled: next });
pushFilters({ ...activeFilters, showUnsimulated: next });
}, [activeFilters, pushFilters]);
const toggleCombinedOnly = useCallback(() => {
const next = !activeFilters.showCombinedOnly;
interactionLogger.record('overview_filter_changed', {
kind: 'combined_only',
enabled: next,
});
pushFilters({ ...activeFilters, showCombinedOnly: next });
}, [activeFilters, pushFilters]);
// Pre-compute the props ActionCard needs for the popover render.
const popoverDetails = popoverPin && actions ? actions[popoverPin.id] : null;
const popoverIndex = useMemo(() => {
if (!popoverPin || !actions) return 0;
const keys = Object.keys(actions);
const idx = keys.indexOf(popoverPin.id);
return idx < 0 ? 0 : idx;
}, [popoverPin, actions]);
// ActionCardPopover wraps the no-op stubs for the
// re-simulate / asset-click callbacks internally — we only
// need to pass the minimal set of handlers below.
return (
<div
data-testid="action-overview-diagram"
style={{
position: 'absolute',
inset: 0,
display: visible ? 'flex' : 'none',
flexDirection: 'column',
background: colors.diagramSurface,
// Sit above the hidden MemoizedSvgContainer for
// actionDiagram (which stays mounted to preserve
// zoom state) so clicks land on the overview.
zIndex: 15,
}}
>
{/* Single-row header: compact pin counter + the
threshold / show-unsimulated / combined-only filters.
The severity (action-card colour) toggles and the
action-type chips were removed — they are now driven
solely by the sidebar's ActionFilterRings, which writes
the same shared `overviewFilters` this view consumes. */}
<div
data-testid="action-overview-header"
style={{
flexShrink: 0,
padding: '4px 10px',
display: 'flex',
alignItems: 'center',
flexWrap: 'nowrap',
gap: '6px',
fontSize: '12px',
background: colors.surfaceMuted,
borderBottom: `1px solid ${colors.borderSubtle}`,
color: colors.textPrimary,
overflowX: 'auto',
whiteSpace: 'nowrap',
}}
>
{hasAnyAction && (
<span
data-testid="overview-pin-counter"
title={`${pins.length} pin${pins.length === 1 ? '' : 's'} on the N-1 network${unsimulatedPins.length > 0 ? ` (+ ${unsimulatedPins.length} un-simulated)` : ''}`}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontWeight: 600, color: colors.textPrimary, flexShrink: 0 }}
>
<span aria-hidden>{'\uD83D\uDCCD'}</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{pins.length}
{unsimulatedPins.length > 0 ? ` (+${unsimulatedPins.length})` : ''}
</span>
</span>
)}
{/* Max-loading threshold now lives in the sidebar
``ActionFilterRings`` strip alongside the severity
+ action-type rings, so the overview header no
longer renders its own copy. */}
<label
data-testid="filter-show-unsimulated"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', flexShrink: 0 }}
title="Show scored-but-not-yet-simulated actions as dimmed pins. Double-click a dimmed pin to run its simulation."
>
<input
type="checkbox"
checked={activeFilters.showUnsimulated}
onChange={toggleUnsimulated}
/>
<span style={{ color: colors.textSecondary }}>Show unsimulated</span>
</label>
<label
data-testid="filter-combined-only"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', flexShrink: 0 }}
title="Show only pins related to combined actions (computed pairs). Constituents stay on the graph dimmed for context."
>
<input
type="checkbox"
checked={activeFilters.showCombinedOnly}
onChange={toggleCombinedOnly}
/>
<span style={{ color: colors.textSecondary }}>Combined only</span>
</label>
</div>
{/* SVG body: the pan/zoom container. Wheel + drag work
via usePanZoom listeners bound in useEffect. */}
<div
ref={containerRef}
className="svg-container nad-action-overview-container"
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
}}
/>
{/* Bottom-left control cluster: zoom buttons + inspect
search. Rendered inside the overview's own flex
layout (not via renderTabOverlay) so they are
trivially shown/hidden with the overview itself. */}
{svgReady && visible && (
<div
data-testid="overview-controls"
style={{
position: 'absolute',
bottom: 12,
left: 12,
zIndex: 100,
display: 'flex',
flexDirection: 'column',
gap: 6,
alignItems: 'flex-start',
pointerEvents: 'none',
}}
>
{isDetached && onToggleTie && (
<div style={{ pointerEvents: 'auto' }}>
<button
data-testid="overview-tie-button"
onClick={onToggleTie}
title={isTied
? 'Untie: pan/zoom and asset focus no longer mirror between this window and the main window'
: 'Tie: pan/zoom and asset focus will be mirrored between this window and the main window\'s active tab'}
style={{
...controlButtonStyle,
padding: '4px 10px',
fontSize: 12,
border: `1px solid ${isTied ? colors.brand : colors.border}`,
backgroundColor: isTied ? colors.brandSoft : colors.surface,
color: isTied ? colors.brand : colors.textSecondary,
}}
>
{isTied ? '\u{1F517} Tied' : '\u{26D3} Tie'}
</button>
</div>
)}
<div style={{ display: 'flex', gap: 4, alignItems: 'center', pointerEvents: 'auto' }}>
<button
onClick={handleZoomIn}
title="Zoom In"
style={controlButtonStyle}
>
+
</button>
<button
onClick={handleReset}
title="Reset view to auto-fit (contingency + overloads + pins)"
style={{ ...controlButtonStyle, padding: '5px 14px', fontSize: 12 }}
>
{'\uD83D\uDD0D Unzoom'}
</button>
<button
onClick={handleZoomOut}
title="Zoom Out"
style={controlButtonStyle}
>
-
</button>
</div>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', position: 'relative', pointerEvents: 'auto' }}>
<InspectSearchField
tabId="action"
inspectQuery={inspectQuery}
onChangeQuery={(_, q) => setInspectQuery(q)}
filteredInspectables={filteredInspectables}
displayName={displayName}
placeholder="🔍 Focus asset..."
inputTestId="overview-inspect-input"
onSelect={focusEquipment}
/>
{inspectQuery && (
<button
onClick={() => setInspectQuery('')}
style={{
background: colors.danger, color: colors.textOnBrand, border: 'none',
borderRadius: 4, padding: '4px 8px', cursor: 'pointer',
fontSize: 12, boxShadow: '0 2px 5px rgba(0,0,0,0.15)',
}}
title="Clear"
>
X
</button>
)}
</div>
</div>
)}
{noBackground && (
<div style={{
position: 'absolute', inset: 0, top: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: colors.textTertiary, fontStyle: 'italic', textAlign: 'center', padding: '40px',
pointerEvents: 'none',
}}>
Load a contingency first, then run the analysis to populate this overview.
</div>
)}
{!noBackground && !hasAnyAction && (
<div style={{
position: 'absolute', inset: 0, top: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: colors.textTertiary, fontStyle: 'italic', textAlign: 'center', padding: '40px',
pointerEvents: 'none',
}}>
Run &ldquo;Analyze &amp; Suggest&rdquo; to see prioritised remedial actions as pins on the network.
</div>
)}
{/*
Click popover.
- Position is fixed (not absolute) because the
click handler captured screen-space coordinates
at click time, and we want the popover to land
exactly next to the pin the operator clicked.
- `portaled` via React means the popover sits in
the same React tree as the overview, so React
owns its lifecycle and we don't need a separate
reconciliation root.
- Outside clicks, Escape, and popover close button
all dismiss it via setPopoverPin(null).
*/}
{/*
Popover.
The ActionCard rendered inside the popover is the
EXACT SAME `ActionCard` component the sidebar feed
uses — both callsites import from the same module.
The `ActionCardPopover` wrapper handles the floating
chrome (position, close button, shadow) and forwards
the minimal set of callbacks this preview view
needs, so when we add or rename a field on
`ActionCard` we only need to update the feed's
render call and the popover wrapper — never
two parallel implementations.
*/}
{visible && popoverPin && popoverDetails && (
<ActionCardPopover
popoverRef={popoverRef}
testId="action-overview-popover"
extraDataAttributes={{
'data-place-above': popoverPin.placeAbove ? 'true' : 'false',
'data-horizontal-align': popoverPin.horizontalAlign,
}}
actionId={popoverPin.id}
details={popoverDetails}
index={popoverIndex}
style={{
...computePopoverStyle(popoverPin, popoverViewport ?? undefined),
width: POPOVER_WIDTH,
maxHeight: POPOVER_MAX_HEIGHT,
overflowY: 'auto',
}}
linesOverloaded={overloadedLines}
monitoringFactor={monitoringFactor}
metaIndex={n1MetaIndex ?? null}
selectedActionIds={selectedActionIds}
rejectedActionIds={rejectedActionIds}
onActivateAction={onActionSelect}
onActionFavorite={onActionFavorite}
onActionReject={onActionReject}
onClose={() => closePopover('close_button')}
displayName={displayName}
/>
)}
</div>
);
};
const controlButtonStyle: React.CSSProperties = {
background: colors.surface,
color: colors.textPrimary,
border: `1px solid ${colors.border}`,
borderRadius: 4,
padding: '5px 12px',
cursor: 'pointer',
fontSize: 14,
fontWeight: 600,
boxShadow: '0 2px 5px rgba(0,0,0,0.15)',
};
export default React.memo(ActionOverviewDiagram);