Spaces:
Sleeping
Sleeping
| // 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 “Analyze & Suggest” 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); | |