Spaces:
Running
Running
| // 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 { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; | |
| import { | |
| applyContingencyHighlight, | |
| applyDeltaVisuals, | |
| applyOverloadedHighlights, | |
| applyActionTargetHighlights, | |
| } from '../utils/svgUtils'; | |
| import { computeN1OverloadHighlights } from '../utils/overloadHighlights'; | |
| import { interactionLogger } from '../utils/interactionLogger'; | |
| import type { AnalysisResult, TabId } from '../types'; | |
| import type { DiagramsState } from './useDiagrams'; | |
| interface UseDiagramHighlightsArgs { | |
| diagrams: DiagramsState; | |
| result: AnalysisResult | null; | |
| selectedContingency: string[]; | |
| selectedOverloads: Set<string>; | |
| monitoringFactor: number; | |
| detachedTabs: Partial<Record<TabId, unknown>>; | |
| } | |
| interface UseDiagramHighlightsReturn { | |
| viewModeForTab: (tab: TabId) => 'network' | 'delta'; | |
| handleViewModeChangeForTab: (tab: TabId, mode: 'network' | 'delta') => void; | |
| } | |
| /** | |
| * Owns the per-tab SVG highlighting pipeline: | |
| * | |
| * - Per-tab Flow/Impacts view-mode state (`detachedViewModes` + | |
| * `viewModeForTab`). Each detached popup keeps its own entry so | |
| * toggling Impacts in a detached window leaves the main window's | |
| * mode untouched, and reattaching drops the detached entry. | |
| * - `applyHighlightsForTab` — the DOM mutation pass that adds | |
| * overload halos, contingency highlights, action-target halos, | |
| * and delta visuals to the N-1 and action SVGs. Ordering matters: | |
| * clone-based highlights MUST run BEFORE `applyDeltaVisuals` | |
| * because delta classes on the original element poison the | |
| * cascade of the cloned highlight layer. See the inline comment | |
| * in this hook for the full cascade-ordering rationale. | |
| * - The driving useEffect that fans the highlight pass across the | |
| * main-window active tab AND every detached tab, guarded by a | |
| * double-rAF on tab switches so `getScreenCTM()` reads settled | |
| * layout. | |
| * | |
| * Re-exports `viewModeForTab` / `handleViewModeChangeForTab` so App | |
| * can pass them down to `<VisualizationPanel>`. | |
| */ | |
| export function useDiagramHighlights({ | |
| diagrams, | |
| result, | |
| selectedContingency, | |
| selectedOverloads, | |
| monitoringFactor, | |
| detachedTabs, | |
| }: UseDiagramHighlightsArgs): UseDiagramHighlightsReturn { | |
| const { | |
| activeTab, nDiagram, n1Diagram, actionDiagram, | |
| selectedActionId, actionViewMode, | |
| } = diagrams; | |
| const staleHighlights = useRef<Set<TabId>>(new Set()); | |
| const prevHighlightTabRef = useRef<TabId>(activeTab); | |
| // Flow vs Impacts view mode is TAB-SCOPED and WINDOW-SCOPED: main | |
| // window has `actionViewMode` (from useDiagrams) while each | |
| // detached tab has its own entry here. Toggling Impacts in a | |
| // detached popup therefore only affects that popup's tab; the | |
| // main window's mode is untouched, and vice versa. A reattach | |
| // clears the detached-mode entry so the tab resumes the | |
| // main-window mode from that point onward. | |
| const [detachedViewModes, setDetachedViewModes] = useState<Partial<Record<TabId, 'network' | 'delta'>>>({}); | |
| const viewModeForTab = useCallback((tab: TabId): 'network' | 'delta' => { | |
| if (detachedTabs[tab]) return detachedViewModes[tab] ?? 'network'; | |
| return actionViewMode; | |
| }, [detachedTabs, detachedViewModes, actionViewMode]); | |
| const handleViewModeChangeForTab = useCallback((tab: TabId, mode: 'network' | 'delta') => { | |
| interactionLogger.record('view_mode_changed', { mode, tab, scope: detachedTabs[tab] ? 'detached' : 'main' }); | |
| if (detachedTabs[tab]) { | |
| setDetachedViewModes(prev => ({ ...prev, [tab]: mode })); | |
| } else { | |
| diagrams.setActionViewMode(mode); | |
| } | |
| }, [detachedTabs, diagrams]); | |
| // On reattach, drop the tab's detached view-mode entry so the main | |
| // window's `actionViewMode` takes over for that tab from that | |
| // point onward. Computed off a ref to avoid an effect→state cycle | |
| // on every render. The setState call is guarded by a stale check, | |
| // but the react-hooks `set-state-in-effect` rule can't see through | |
| // that (it bans setState in effects categorically), and the behavior | |
| // — a re-detach after reattach must restart from the main-window | |
| // mode rather than resume the previous detached mode — must be | |
| // preserved byte-for-byte, so we suppress the rule for this one call. | |
| const detachedViewModesRef = useRef(detachedViewModes); | |
| useLayoutEffect(() => { detachedViewModesRef.current = detachedViewModes; }, [detachedViewModes]); | |
| useEffect(() => { | |
| const current = detachedViewModesRef.current; | |
| let hasStale = false; | |
| for (const tabId of Object.keys(current) as TabId[]) { | |
| if (!detachedTabs[tabId]) { hasStale = true; break; } | |
| } | |
| if (!hasStale) return; | |
| const next: Partial<Record<TabId, 'network' | 'delta'>> = {}; | |
| for (const tabId of Object.keys(current) as TabId[]) { | |
| if (detachedTabs[tabId]) next[tabId] = current[tabId]; | |
| } | |
| // eslint-disable-next-line react-hooks/set-state-in-effect -- preserves the reattach-prune behavior from the pre-extraction App.tsx; guarded by the hasStale short-circuit above. | |
| setDetachedViewModes(next); | |
| }, [detachedTabs]); | |
| const applyHighlightsForTab = useCallback((tab: TabId, mode?: 'network' | 'delta') => { | |
| const effectiveMode = mode ?? viewModeForTab(tab); | |
| const overloadedLines = result?.lines_overloaded || []; | |
| // For the N-1 tab, the overloads are known as soon as the N-1 | |
| // diagram comes back from the backend (`n1Diagram.lines_overloaded`) | |
| // — well before any action analysis has run. Falling back to that | |
| // list lets the orange overload halos show up immediately on the | |
| // N-1 view, matching the standalone interface and what the user | |
| // expects to see right after picking a contingency. Once analysis | |
| // runs and `result.lines_overloaded` becomes available, we use that. | |
| // In both cases the user's selected-overload set (from the | |
| // Overloads panel) further filters the list down. | |
| const n1OverloadedLines = computeN1OverloadHighlights( | |
| overloadedLines, | |
| n1Diagram?.lines_overloaded, | |
| selectedOverloads, | |
| ); | |
| if (tab === 'contingency') { | |
| if (diagrams.n1SvgContainerRef.current) { | |
| // IMPORTANT: run highlight CLONES before applyDeltaVisuals. | |
| // The clone-based highlights (`applyOverloadedHighlights`, | |
| // `applyContingencyHighlight`) use cloneNode(true) on the | |
| // original SVG element. If applyDeltaVisuals has already | |
| // tagged the original with `nad-delta-positive/negative/grey`, | |
| // the clone inherits that class — and because the `.nad-delta-*` | |
| // CSS is declared LATER in App.css than `.nad-contingency-highlight` | |
| // / `.nad-overloaded`, the delta rule wins the cascade and the | |
| // halo becomes a thin orange/blue stroke, effectively making the | |
| // highlight disappear in Impacts mode. Cloning first guarantees | |
| // the halos stay on a pristine copy of the element. | |
| // | |
| // Stacking order (SVG draws later-appended clones ON TOP): | |
| // contingency halo appended FIRST (bottom) → overload halos | |
| // appended SECOND (on top). Matches the product spec | |
| // "action halo > overload halo > contingency halo". | |
| // | |
| // Overloaded lines must also be highlighted in BOTH Flows and | |
| // Impacts modes — the user looks at the Impacts view to see how | |
| // the action redistributes flows AND which lines are still | |
| // (or newly) overloaded; suppressing the halos in delta mode | |
| // hides exactly that information. | |
| applyContingencyHighlight(diagrams.n1SvgContainerRef.current, diagrams.n1MetaIndex, selectedContingency); | |
| if (diagrams.n1MetaIndex && n1OverloadedLines.length > 0) { | |
| applyOverloadedHighlights(diagrams.n1SvgContainerRef.current, diagrams.n1MetaIndex, n1OverloadedLines); | |
| } | |
| applyDeltaVisuals(diagrams.n1SvgContainerRef.current, n1Diagram, diagrams.n1MetaIndex, effectiveMode === 'delta'); | |
| } | |
| } | |
| if (tab === 'action') { | |
| const actionDetail = result?.actions?.[selectedActionId || '']; | |
| if (actionDetail) { | |
| // Same ordering rule as the N-1 tab: clone-based highlights | |
| // first (so they capture pristine elements), delta visuals | |
| // last. Overload halos render in both Flows and Impacts modes. | |
| // | |
| // ActionCard severity classification (`components/ActionCard.tsx:76-78`): | |
| // - red: `max_rho > monitoringFactor` ("Still overloaded") | |
| // - orange: `max_rho > monitoringFactor - 0.05` ("Solved — low margin") | |
| // - green: else ("Solves overload") | |
| // When the card says "Solved" (orange or green) no line in the | |
| // post-action state exceeds the operator's tolerance threshold, | |
| // so overload halos on the NAD contradict the card's label. | |
| // The backend's manual-simulation path flags `lines_overloaded_after` | |
| // on raw rho >= monitoring_factor (0.95) whereas the analysis | |
| // path uses raw rho >= 1.0 (`analysis_mixin.py:97` vs | |
| // `simulation_mixin.py:536`). In the low-margin band — raw rho | |
| // in `[mf, 1.0)` — the two paths disagree: manual ships a | |
| // non-empty list, analysis ships an empty one. Without this | |
| // gate, halos appear on manual low-margin actions but not on | |
| // suggested ones with the same displayed max_rho — the exact | |
| // user-reported bug (commit-time 2026-04-20). | |
| const isSolved = | |
| actionDetail.max_rho != null && actionDetail.max_rho <= monitoringFactor; | |
| let overloadsToHighlight: string[] = []; | |
| if (!isSolved) { | |
| if (actionDetail.lines_overloaded_after && actionDetail.lines_overloaded_after.length > 0) { | |
| overloadsToHighlight = actionDetail.lines_overloaded_after; | |
| } else { | |
| // Fallback for legacy results or actions without full enrichment | |
| if (overloadedLines.length > 0 && actionDetail.rho_after) { | |
| overloadedLines.forEach((name, i) => { | |
| const rho = actionDetail.rho_after![i]; | |
| if (rho != null && rho > monitoringFactor) { | |
| overloadsToHighlight.push(name); | |
| } | |
| }); | |
| } | |
| if (actionDetail.max_rho != null && actionDetail.max_rho > monitoringFactor && actionDetail.max_rho_line) { | |
| if (!overloadsToHighlight.includes(actionDetail.max_rho_line)) { | |
| overloadsToHighlight.push(actionDetail.max_rho_line); | |
| } | |
| } | |
| } | |
| } | |
| // Stacking order (SVG draws later-appended clones ON TOP): | |
| // contingency halo appended FIRST (bottom), overload halos | |
| // SECOND (middle), action-target halo THIRD (top). Matches | |
| // the product spec "action halo > overload halo > contingency | |
| // halo". | |
| if (diagrams.actionSvgContainerRef.current) { | |
| applyContingencyHighlight(diagrams.actionSvgContainerRef.current, diagrams.actionMetaIndex, selectedContingency); | |
| } | |
| if (diagrams.actionSvgContainerRef.current && diagrams.actionMetaIndex) { | |
| applyOverloadedHighlights(diagrams.actionSvgContainerRef.current, diagrams.actionMetaIndex, overloadsToHighlight); | |
| } | |
| if (diagrams.actionSvgContainerRef.current) { | |
| applyActionTargetHighlights(diagrams.actionSvgContainerRef.current, diagrams.actionMetaIndex, actionDetail, selectedActionId); | |
| } | |
| } | |
| else { | |
| if (diagrams.actionSvgContainerRef.current) { | |
| applyActionTargetHighlights(diagrams.actionSvgContainerRef.current, null, null, null); | |
| } | |
| } | |
| // Delta visuals run LAST so they decorate the originals without | |
| // contaminating the highlight clones already in the background | |
| // layer. | |
| applyDeltaVisuals(diagrams.actionSvgContainerRef.current, actionDiagram, diagrams.actionMetaIndex, effectiveMode === 'delta'); | |
| } | |
| // QW14: depend on the SPECIFIC `diagrams` members this callback reads, not | |
| // the whole `diagrams` object — its identity churns on every pan/zoom | |
| // settle (nPZ/n1PZ/actionPZ change), which re-created this callback and | |
| // re-fired the driving effect below, re-running the whole highlight | |
| // pipeline on every gesture end. The two *SvgContainerRef values are stable | |
| // ref objects and the two *MetaIndex values are memoized off diagram | |
| // metadata, so no legitimate trigger is lost (the driving effect also lists | |
| // nDiagram/n1Diagram/actionDiagram + the metaIndexes directly). | |
| }, [n1Diagram, actionDiagram, result, selectedActionId, selectedContingency, | |
| diagrams.n1SvgContainerRef, diagrams.actionSvgContainerRef, | |
| diagrams.n1MetaIndex, diagrams.actionMetaIndex, | |
| monitoringFactor, viewModeForTab, selectedOverloads]); | |
| useEffect(() => { | |
| const isTabSwitch = prevHighlightTabRef.current !== activeTab; | |
| prevHighlightTabRef.current = activeTab; | |
| const otherTabs: TabId[] = ['n', 'contingency', 'action'].filter(t => t !== activeTab) as TabId[]; | |
| otherTabs.forEach(t => staleHighlights.current.add(t)); | |
| // Apply highlights + delta visuals to both the main window's | |
| // active tab AND every currently-detached tab — because Impacts | |
| // mode must keep working inside a detached popup when only the | |
| // popup's view mode changes (the main window may be showing a | |
| // different tab entirely). | |
| const applyAll = () => { | |
| applyHighlightsForTab(activeTab); | |
| staleHighlights.current.delete(activeTab); | |
| for (const detachedId of Object.keys(detachedTabs) as TabId[]) { | |
| if (detachedId === activeTab) continue; | |
| if (detachedId === 'overflow') continue; | |
| applyHighlightsForTab(detachedId); | |
| staleHighlights.current.delete(detachedId); | |
| } | |
| }; | |
| if (isTabSwitch) { | |
| // Double rAF to ensure browser layout is settled before getScreenCTM() | |
| const id = requestAnimationFrame(() => { | |
| requestAnimationFrame(() => { | |
| applyAll(); | |
| }); | |
| }); | |
| return () => cancelAnimationFrame(id); | |
| } else { | |
| applyAll(); | |
| } | |
| }, [nDiagram, n1Diagram, actionDiagram, diagrams.nMetaIndex, diagrams.n1MetaIndex, diagrams.actionMetaIndex, result, selectedActionId, actionViewMode, detachedViewModes, detachedTabs, activeTab, selectedContingency, selectedOverloads, applyHighlightsForTab]); | |
| return { viewModeForTab, handleViewModeChangeForTab }; | |
| } | |