// 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 | 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; /** Rejected-action set — pins are dimmed with a red cross. */ rejectedActionIds?: Set; /** * 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) => 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>; /** * 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 = ({ 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(() => { 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(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 `` 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(() => { 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 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 ), // 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(); 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(() => { 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(() => { 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(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(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 (