// 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 { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import './App.css'; import VisualizationPanel from './components/VisualizationPanel'; import ActionFeed from './components/ActionFeed'; import AntennaNotice from './components/AntennaNotice'; import Header from './components/Header'; import AppSidebar from './components/AppSidebar'; import NotificationHost from './components/NotificationHost'; import type { Notice } from './components/NoticesPanel'; import SettingsModal from './components/modals/SettingsModal'; import ReloadSessionModal from './components/modals/ReloadSessionModal'; import ConfirmationDialog from './components/modals/ConfirmationDialog'; import type { ConfirmDialogState } from './components/modals/ConfirmationDialog'; import { api } from './api'; import type { ActionDetail, ActionOverviewFilters, TabId, MetadataIndex, RecommenderDisplayConfig, UnsimulatedActionScoreInfo } from './types'; import { useSettings } from './hooks/useSettings'; import { useActions } from './hooks/useActions'; import { useAnalysis } from './hooks/useAnalysis'; import { useDiagrams } from './hooks/useDiagrams'; import { useSession } from './hooks/useSession'; import { useDetachedTabs } from './hooks/useDetachedTabs'; import { useTiedTabsSync, type PZInstance } from './hooks/useTiedTabsSync'; import { useContingencyFetch } from './hooks/useContingencyFetch'; import { useDiagramHighlights } from './hooks/useDiagramHighlights'; import { useManualSimulation } from './hooks/useManualSimulation'; import { interactionLogger } from './utils/interactionLogger'; import { gameBridge } from './game/gameBridge'; import type { GameStudy } from './game/types'; import { DEFAULT_ACTION_OVERVIEW_FILTERS } from './utils/actionTypes'; import { apiErrorMessage } from './utils/apiError'; import { notifyError, notifications } from './utils/notifications'; import { attachVlInteractions } from './utils/svgUtils'; import { buildOverflowPinPayload, buildOverflowUnsimulatedPinPayload, } from './utils/svg/overflowPinPayload'; function App() { // ===== Settings Hook ===== const settings = useSettings(); const { // Paths and values used in App-level logic (handleApplySettings, handleLoadConfig, wrappedSaveResults/RestoreSession) configFilePath, changeConfigFilePath, lastActiveConfigFilePath, networkPath, setNetworkPath, actionPath, setActionPath, layoutPath, setLayoutPath, outputFolderPath, minLineReconnections, setMinLineReconnections, minCloseCoupling, setMinCloseCoupling, minOpenCoupling, setMinOpenCoupling, minLineDisconnections, setMinLineDisconnections, nPrioritizedActions, setNPrioritizedActions, minPst, setMinPst, minLoadShedding, setMinLoadShedding, minRenewableCurtailmentActions, setMinRenewableCurtailmentActions, minRedispatch, setMinRedispatch, allowedActionTypes, setAllowedActionTypes, ignoreReconnections, setIgnoreReconnections, linesMonitoringPath, setLinesMonitoringPath, monitoredLinesCount, totalLinesCount, showMonitoringWarning, setShowMonitoringWarning, monitoringFactor, setMonitoringFactor, preExistingOverloadThreshold, setPreExistingOverloadThreshold, pypowsyblFastMode, setPypowsyblFastMode, actionDictFileName, actionDictStats, setIsSettingsOpen, setSettingsTab, pickSettingsPath, handleOpenSettings, recommenderModel, setRecommenderModel, availableModels, buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, createCurrentBackup, setSettingsBackup } = settings; /** * Currently APPLIED contingency — ordered list of element IDs to * disconnect simultaneously. Empty list means N state. Single-item * list is the legacy N-1 case; longer lists drive N-K studies. * The user builds the list via ``pendingContingency`` and commits * it with the Apply button in the Header. */ const [selectedContingency, setSelectedContingency] = useState([]); /** * Pending element IDs the user is composing in the Header before * pressing Apply. Confirming applies the list to * ``selectedContingency`` and triggers the diagram fetch. */ const [pendingContingency, setPendingContingency] = useState([]); const [branches, setBranches] = useState([]); const [voltageLevels, setVoltageLevels] = useState([]); /** ID → human-readable name for branches (lines + transformers) and VLs. */ const [nameMap, setNameMap] = useState>({}); /** VL id → substation id. Loaded once after config-load and used to * anchor the action-overview pins on the overflow graph (whose * nodes are pypowsybl substation ids). */ const [vlToSubstation, setVlToSubstation] = useState>({}); /** Whether the user has switched ON the pin overlay on the overflow * graph. Default OFF; toggle is disabled until Step 2 has produced * a non-empty `result.actions` map. */ const [overflowPinsEnabled, setOverflowPinsEnabled] = useState(false); const [configLoading, setConfigLoading] = useState(false); // Errors surface through the shared notification store (D5). This // adapter preserves the historical `setError(msg)` / `setError('')` // (raise / clear) contract used across App and threaded into hooks. const setError = useCallback((message: string) => { if (message) notifyError(message); else notifications.clearSeverity('error'); }, []); /** Resolve an element or VL ID to its display name. Falls back to the ID. */ const displayName = useCallback((id: string) => nameMap[id] || id, [nameMap]); // ===== Detached Visualization Tabs (must be instantiated BEFORE useDiagrams // so that the detached-tabs map can be threaded into useDiagrams → usePanZoom, // keeping a detached tab interactive even when it's not the main `activeTab`.) const detachedTabsHook = useDetachedTabs({ onPopupBlocked: () => setError('Popup blocked by the browser. Please allow popups for this site to detach tabs.'), }); const { detachedTabs, detach: detachTab, reattach: reattachTab, focus: focusDetachedTab } = detachedTabsHook; const diagrams = useDiagrams(branches, voltageLevels, selectedContingency, detachedTabs); // ===== Action Overview PZ (for tied-tab sync) ===== // The action overview has its own independent usePanZoom instance // (it renders the N-1 NAD as a background with pins). We need to // include it in the tie system so that when the action tab is // detached and showing the overview (no selectedActionId), zoom / // focus changes are mirrored to the main window. // // This MUST be React state (not a ref) so that when the overview's // viewBox changes inside ActionOverviewDiagram, the new PZ instance // propagates up to App via the onPzChange callback, triggering a // re-render. That re-render updates `actionVb` inside // useTiedTabsSync's deps, letting it detect the change and mirror // it to the main window. A ref would silently hold the new value // without triggering the sync hook — making detached→main sync // one-directional. const [overviewPz, setOverviewPz] = useState(null); const handleOverviewPzChange = useCallback((pz: PZInstance) => { setOverviewPz(pz); }, []); // When the overview is visible (no selected action), use its PZ // for the 'action' slot in the tie map. Otherwise fall back to // the action-variant diagram's PZ. const actionPZForTie = (!diagrams.selectedActionId && overviewPz) ? overviewPz : diagrams.actionPZ; // ===== Tied Detached Tabs ===== // When a detached tab is "tied", its viewBox is mirrored one-way // into the main window's active tab on every pan/zoom change — // supporting side-by-side comparison workflows. See // docs/features/detachable-viz-tabs.md#tied-detached-tabs for the full // design rationale. const tiedTabsHook = useTiedTabsSync( { 'n': diagrams.nPZ, 'contingency': diagrams.n1PZ, 'action': actionPZForTie }, diagrams.activeTab, detachedTabs, ); const { isTied: isTabTied, toggleTie: toggleTabTie } = tiedTabsHook; // Confirmation dialog state for contingency change / load study / // apply settings / change network path. const [confirmDialog, setConfirmDialog] = useState(null); // Collapsed mode for the left sidebar. When true the sidebar shrinks // to a thin strip and the visualization panel takes the full width. // Toggleable from the Clear/Collapse caret rendered by `AppSidebar`. const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const handleToggleSidebarCollapsed = useCallback(() => { setSidebarCollapsed(c => { const next = !c; interactionLogger.record('sidebar_collapsed_toggled', { collapsed: next }); return next; }); }, []); // Path of the network file the currently-loaded study was loaded from. // Updated after every successful handleLoadConfig / applySettings, used // by requestNetworkPathChange to detect "user is switching to a // different network while a study is already loaded" and prompt for // confirmation before silently dropping the in-flight work. const committedNetworkPathRef = useRef(''); // ===== Hook integrations ===== const actionsHook = useActions(); const { selectedActionIds, manuallyAddedIds, rejectedActionIds, suggestedByRecommenderIds } = actionsHook; const analysis = useAnalysis(); const { result, setResult, pendingAnalysisResult, analysisLoading, selectedOverloads, monitorDeselected, additionalLinesToCut, committedAdditionalLinesToCut, } = analysis; const { activeTab, nDiagram, n1Diagram, n1Loading, selectedActionId, actionDiagram, actionDiagramLoading, actionViewMode, inspectQuery, uniqueVoltages, voltageRange, vlOverlay, handleViewModeChange, handleManualZoomIn, handleManualZoomOut, handleManualReset, handleVlDoubleClick, handleOverlaySldTabChange, handleOverlayClose, inspectableItems, nSvgContainerRef, n1SvgContainerRef, actionSvgContainerRef, showVoltageLevelNames, setShowVoltageLevelNames, } = diagrams; // When a pin on the overview is single-clicked we want the sidebar // action feed to scroll to the matching card without selecting it // (which would drill into the action-variant view). This counter- // based state lets ActionFeed react on every click even if the same // pin is tapped twice in a row (a plain id string would not trigger // a re-render on the second identical value). const [scrollTarget, setScrollTarget] = useState<{ id: string; seq: number } | null>(null); const scrollSeqRef = useRef(0); const handlePinPreview = useCallback((actionId: string) => { scrollSeqRef.current += 1; setScrollTarget({ id: actionId, seq: scrollSeqRef.current }); }, []); // Shared filter state for the Remedial Action overview. The same // `ActionOverviewFilters` drives (a) the pin visibility + dimmed // un-simulated pins on ActionOverviewDiagram and (b) the card // visibility in the sidebar ActionFeed, so both views stay in // lock-step regardless of which entry point the operator uses. const [overviewFilters, setOverviewFilters] = useState(DEFAULT_ACTION_OVERVIEW_FILTERS); // Flat list of action ids that appear in `action_scores` but are // not yet simulated. Feeds ActionOverviewDiagram's un-simulated pin // layer. We dedupe across action_scores..scores to avoid // pinning the same id twice. Computed alongside `unsimulatedActionInfo` // so the two structures always stay in sync. const { unsimulatedActionIds, unsimulatedActionInfo } = useMemo(() => { const scores = analysis.result?.action_scores; if (!scores) return { unsimulatedActionIds: [] as string[], unsimulatedActionInfo: {} as Record }; const simulated = new Set(Object.keys(analysis.result?.actions ?? {})); const ids: string[] = []; const info: Record = {}; const seen = new Set(); for (const [type, rawData] of Object.entries(scores)) { const data = rawData as { scores?: Record; mw_start?: Record; tap_start?: Record; }; const per = data.scores ?? {}; const mwStartMap = data.mw_start ?? {}; const tapStartMap = data.tap_start ?? {}; // Rank is assigned by descending score so the operator sees // the top-scoring un-simulated candidate as "rank 1". const rankedEntries = Object.entries(per).sort(([, a], [, b]) => b - a); const maxScoreInType = rankedEntries.length > 0 ? rankedEntries[0][1] : 0; const countInType = rankedEntries.length; for (let i = 0; i < rankedEntries.length; i++) { const [id, score] = rankedEntries[i]; if (simulated.has(id) || seen.has(id)) continue; seen.add(id); ids.push(id); info[id] = { type, score, mwStart: mwStartMap[id] ?? null, tapStart: tapStartMap[id] ?? null, rankInType: i + 1, countInType, maxScoreInType, }; } } return { unsimulatedActionIds: ids, unsimulatedActionInfo: info }; }, [analysis.result?.action_scores, analysis.result?.actions]); const recommenderConfig = useMemo(() => ({ minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, nPrioritizedActions, ignoreReconnections, }), [ minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, nPrioritizedActions, ignoreReconnections, ]); const session = useSession(); const { showReloadModal, setShowReloadModal, sessionList, sessionListLoading, sessionRestoring } = session; // ===== Detached Visualization Tabs ===== // `useDetachedTabs` is instantiated higher up so its map can be passed // into `useDiagrams` (see above). Here we wire the detach/reattach // callbacks that depend on diagrams (activeTab fallback logic) and // the interaction logger. const handleDetachTab = useCallback((tabId: TabId) => { interactionLogger.record('tab_detached', { tab: tabId }); const entry = detachTab(tabId); // If the user detached the currently-active tab, switch the main // window to any other available tab so the main panel doesn't show // an empty slot by default. Prefers the first tab that is not itself // detached; falls back to 'n' (which is always available). if (entry && diagrams.activeTab === tabId) { const order: TabId[] = ['n', 'contingency', 'action', 'overflow']; const fallback = order.find(t => t !== tabId && !detachedTabs[t]); diagrams.setActiveTab(fallback ?? 'n'); } }, [detachTab, diagrams, detachedTabs]); const handleReattachTab = useCallback((tabId: TabId) => { interactionLogger.record('tab_reattached', { tab: tabId }); reattachTab(tabId); }, [reattachTab]); // ===== Cross-Hook Wiring wrappers (all memoized) ===== // Clear all contingency-related analysis state (preserves network/config) const clearContingencyState = useCallback(() => { analysis.setResult(null); analysis.setPendingAnalysisResult(null); analysis.setSelectedOverloads(new Set()); analysis.setMonitorDeselected(false); analysis.setAdditionalLinesToCut(new Set()); analysis.setCommittedAdditionalLinesToCut(new Set()); actionsHook.clearActionState(); diagrams.setSelectedActionId(null); diagrams.setActionDiagram(null); // Do NOT reset activeTab to 'n' here — the caller (fetchN1) sets // it to 'contingency' immediately. Resetting to 'n' interfered with the // auto-zoom effect on the second contingency change. diagrams.setVlOverlay(null); // Fresh contingency / study starts in hierarchical mode so the // toggle matches the backend's freshly-cleared overflow cache. diagrams.setOverflowLayoutMode('hierarchical'); // Clear both notification channels so a failed-analysis error or a // stale info toast doesn't outlive the contingency it described. setError(''); analysis.setInfoMessage(''); diagrams.setInspectQuery(''); // Do NOT reset lastZoomState here. Resetting it causes the auto-zoom // effect to detect a spurious "branch change" during the same render // cycle in which the old n1Diagram SVG is still mounted, firing the // zoom on stale data and consuming the intent before the new diagram // loads. Leaving lastZoomState intact lets the natural selectedContingency // change trigger the zoom correctly after the new SVG is ready. }, [setError, actionsHook, analysis, diagrams]); // Narrower reset used when re-running the analysis on the SAME // contingency. Unlike `clearContingencyState`, this preserves any // manually-added ("first guess") actions so they stay in the // Selected Actions section through the analysis run — mirroring the // standalone interface, which filters result.actions down to the // is_manual=true subset on Analyze & Suggest instead of wiping // everything. // // Specifically: keeps manuallyAddedIds, keeps the selected-action // set restricted to manually-added IDs, and filters result.actions // to the is_manual subset (with pdf / lines_overloaded cleared so // the UI correctly shows the "analysis in progress" state). const resetForAnalysisRun = useCallback(() => { // Keep entries the operator has invested in across a re-run: // - manually-added "first guess" actions (`is_manual=true`), // - starred recommender suggestions (handleActionFavorite stamps // `is_manual=true` on those too — but the `selectedActionIds` // trim below also has to keep them for the post-step2 merge // to recognise them as Selected), // - rejected recommender suggestions (so the operator's veto // survives the re-run — the new step2 may re-emit the id but // the rejected-id set still rules). // Wipe pdf / lines_overloaded so the UI renders the // "analysis in progress" state. analysis.setResult(prev => { if (!prev) return null; const kept: Record = {}; for (const [id, data] of Object.entries(prev.actions || {})) { const userTouched = data.is_manual || actionsHook.selectedActionIds.has(id) || actionsHook.rejectedActionIds.has(id) || actionsHook.manuallyAddedIds.has(id); if (userTouched) kept[id] = data; } return { ...prev, actions: kept, lines_overloaded: [], pdf_url: null, pdf_path: null, }; }); analysis.setPendingAnalysisResult(null); analysis.setMonitorDeselected(false); // Preserve the full starred / rejected sets — only the // recommender-only suggestions set is wiped (step2 rebuilds it). actionsHook.setSuggestedByRecommenderIds(new Set()); // Don't wipe selectedActionId if it points to an action the // operator has invested in — keeps the variant diagram mounted // through the re-run. const sel = diagrams.selectedActionId; if ( sel && !actionsHook.manuallyAddedIds.has(sel) && !actionsHook.selectedActionIds.has(sel) ) { diagrams.setSelectedActionId(null); diagrams.setActionDiagram(null); } diagrams.setVlOverlay(null); setError(''); analysis.setInfoMessage(''); diagrams.setInspectQuery(''); }, [setError, actionsHook, analysis, diagrams]); // Full reset: contingency state + network/diagram state const resetAllState = useCallback(() => { clearContingencyState(); diagrams.setActiveTab('n'); diagrams.setNDiagram(null); diagrams.setN1Diagram(null); diagrams.setOriginalViewBox(null); diagrams.setActionViewMode('network'); diagrams.setShowVoltageLevelNames(true); diagrams.setN1Loading(false); diagrams.setActionDiagramLoading(false); diagrams.committedBranchRef.current = []; diagrams.actionSyncSourceRef.current = null; diagrams.lastZoomState.current = { query: '', branch: '' }; setSelectedContingency([]); setPendingContingency([]); setShowMonitoringWarning(false); setVlToSubstation({}); setOverflowPinsEnabled(false); // Dismiss any in-flight confirmation dialog. A stale "Change // Contingency?" dialog left over from a prior gesture (or a // session reload race that briefly flipped the ref window) must // not survive a fresh reset. setConfirmDialog(null); }, [clearContingencyState, diagrams, setShowMonitoringWarning]); // Sidebar visibility gates. The picker card and the action feed // flip together at the moment the operator has played a contingency. // From that point on the sticky banner echoes the contingency + // overload labels with its own Clear shortcut, so the picker would // just duplicate state — and the feed becomes useful (Analyze & // Suggest, manual selection, …). Pre-trigger states keep the picker // visible and the feed hidden so the sidebar stays focused. const hasCommittedContingency = selectedContingency.length > 0; const sidebarSwitchedToFeed = hasCommittedContingency; // Pre-compute the pin descriptors posted to the overflow-graph // iframe. Memoised so unrelated re-renders don't churn the iframe // postMessage. The toggle is gated on Step 2 having delivered at // least one action — the iframe overlay is otherwise useless. const overflowPinsAvailable = useMemo( () => !analysisLoading && !!result?.actions && Object.keys(result.actions).length > 0, [analysisLoading, result?.actions], ); const overflowPins = useMemo( () => overflowPinsAvailable ? buildOverflowPinPayload( result?.actions ?? null, diagrams.n1MetaIndex ?? null, vlToSubstation, monitoringFactor, selectedActionIds, rejectedActionIds, undefined, overviewFilters, ) : [], [overflowPinsAvailable, result?.actions, diagrams.n1MetaIndex, vlToSubstation, monitoringFactor, selectedActionIds, rejectedActionIds, overviewFilters], ); // Un-simulated overflow pins. Built only when the operator has // ticked ``Show unsimulated`` in the Action-Overview filter row // (which is mirrored in the iframe sidebar's filter panel). // Identical contract to the Action Overview pin layer: // - dimmed grey pin with '?' label, // - dblclick triggers a manual simulation rather than the SLD // drill-down, // - skipped when the id is already in ``result.actions``. const overflowUnsimulatedPins = useMemo( () => (overflowPinsAvailable && overviewFilters?.showUnsimulated) ? buildOverflowUnsimulatedPinPayload( unsimulatedActionIds, new Set(Object.keys(result?.actions ?? {})), diagrams.n1MetaIndex ?? null, vlToSubstation, unsimulatedActionInfo, undefined, overviewFilters, ) : [], [overflowPinsAvailable, overviewFilters, unsimulatedActionIds, unsimulatedActionInfo, result?.actions, diagrams.n1MetaIndex, vlToSubstation], ); // Pin payload posted to the iframe is the union of simulated + // un-simulated pins. The overlay differentiates them via the // ``unsimulated`` flag on each pin. const allOverflowPins = useMemo( () => [...overflowPins, ...overflowUnsimulatedPins], [overflowPins, overflowUnsimulatedPins], ); // Auto-disable the toggle when the gate goes away (e.g. user // applied new settings, which clears the result). Without this, // the toggle would stay ON but the toolbar would show 'OFF' style // because `overflowPinsAvailable` is false. useEffect(() => { if (!overflowPinsAvailable && overflowPinsEnabled) { setOverflowPinsEnabled(false); } }, [overflowPinsAvailable, overflowPinsEnabled]); const wrappedActionSelect = useCallback( (actionId: string | null) => diagrams.handleActionSelect(actionId, result, selectedContingency, voltageLevels.length, setResult, setError), [diagrams, result, selectedContingency, voltageLevels.length, setResult, setError] ); // Overflow Analysis tab's Hierarchical / Geo toggle — the hook's // handler needs `setResult` / `setError` to merge the new // pdf_url back into `analysisHook.result`, same pattern as // `handleActionSelect` above. const wrappedOverflowLayoutChange = useCallback( (mode: 'hierarchical' | 'geo') => diagrams.handleOverflowLayoutChange(mode, setResult, setError), [diagrams, setResult, setError] ); // Force-select variant used after a (re)simulation. This skips the // "already selected → deselect" toggle path in handleActionSelect so the // newly-simulated action diagram is always re-fetched. const wrappedForcedActionSelect = useCallback( (actionId: string | null) => diagrams.handleActionSelect(actionId, result, selectedContingency, voltageLevels.length, setResult, setError, true), [diagrams, result, selectedContingency, voltageLevels.length, setResult, setError] ); const wrappedActionFavorite = useCallback( (actionId: string) => { // Game Mode caps the number of committed (starred) actions per study. // Block starring a *new* action once the cap is reached; un-starring // and re-starring an existing pick stays allowed. if ( !actionsHook.selectedActionIds.has(actionId) && !gameBridge.canStarAnotherAction(actionsHook.selectedActionIds.size) ) { setError( `Game Mode: at most ${gameBridge.getMaxActions()} actions per study — un-star one to pick another.`, ); return; } actionsHook.handleActionFavorite(actionId, setResult); }, [actionsHook, setResult, setError] ); // Manually-added (first-time simulated) action. Same SLD refresh // rationale as `wrappedActionResimulated` below: the new detail // carries fresh `load_shedding_details` / `curtailment_details` / // `pst_details` arrays which the SLD highlight pass needs to see. const wrappedManualActionAdded = useCallback( (actionId: string, detail: ActionDetail, linesOverloaded: string[], origin: string = 'user') => { actionsHook.handleManualActionAdded(actionId, detail, linesOverloaded, setResult, wrappedForcedActionSelect, origin); diagrams.refreshSldIfAction(actionId); }, [actionsHook, setResult, wrappedForcedActionSelect, diagrams] ); // Manual-simulation flows (double-click an un-simulated overview pin + // the interactive SLD topology/injection edit) live in // useManualSimulation (D4). App owns the collaborators and passes them // in; the hook returns the SLD-edit state + the two simulate handlers // the JSX below consumes. const { sldTopologyEdit, sldEditBusy, sldEditBaseActionId, sldPreview, sldPreviewLoading, handleSimulateUnsimulatedAction, handleSimulateSldEdit, } = useManualSimulation({ diagrams, selectedContingency, result, voltageLevels, wrappedManualActionAdded, setError, }); // Re-simulation of an already-present action (edit Target MW / tap on a // suggested card). Does NOT move the action into the selected bucket. // // When the SLD overlay is open on this action, refresh it so the // per-equipment load-shedding / curtailment / PST highlights (and the // flow deltas baked into the backend SLD response) reflect the new // simulation result instead of the pre-resimulation snapshot. // Covers all three editable action families: MW edits on load-shedding // and renewable-curtailment, and tap edits on PST — all three flow // through `onActionResimulated` in ActionFeed.tsx, so one refresh // hook-up covers them. const wrappedActionResimulated = useCallback( (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => { actionsHook.handleActionResimulated(actionId, detail, linesOverloaded, setResult, wrappedForcedActionSelect); diagrams.refreshSldIfAction(actionId); }, [actionsHook, setResult, wrappedForcedActionSelect, diagrams] ); const handleUpdateCombinedEstimation = useCallback( (pairId: string, estimation: { estimated_max_rho: number; estimated_max_rho_line: string }) => { console.log('[handleUpdateCombinedEstimation] called with pairId:', pairId, 'estimation:', estimation); setResult(prev => { console.log('[handleUpdateCombinedEstimation] prev combined_actions keys:', prev?.combined_actions ? Object.keys(prev.combined_actions) : 'null', 'pairId exists:', !!prev?.combined_actions?.[pairId]); if (!prev?.combined_actions?.[pairId]) return prev; return { ...prev, combined_actions: { ...prev.combined_actions, [pairId]: { ...prev.combined_actions[pairId], ...estimation }, }, }; }); }, [setResult] ); const wrappedRunAnalysis = useCallback( () => analysis.handleRunAnalysis(selectedContingency, resetForAnalysisRun, actionsHook.setSuggestedByRecommenderIds, diagrams.setActiveTab), [analysis, selectedContingency, resetForAnalysisRun, actionsHook.setSuggestedByRecommenderIds, diagrams.setActiveTab] ); // Wipe recommender-produced suggestions the operator has NOT // interacted with. Keeps starred (selectedActionIds), rejected // (rejectedActionIds) and manually-added (manuallyAddedIds / // is_manual) entries intact so the user can re-run with a // different model without losing their decisions. Tracking // ``suggestedByRecommenderIds`` (the source-of-truth set populated // during the step-2 stream) keeps us from accidentally dropping // manual-only entries that happen to share an id with a previous // recommender suggestion. This does NOT re-run the analysis — the // operator clears, optionally swaps the model, then presses // Analyze & Suggest themselves. const performClearSuggested = useCallback(() => { interactionLogger.record('suggested_actions_cleared', { n_cleared: Array.from(suggestedByRecommenderIds).filter(id => !selectedActionIds.has(id) && !rejectedActionIds.has(id) && !manuallyAddedIds.has(id) ).length, }); setResult(prev => { if (!prev?.actions) return prev; const filtered: Record = {}; for (const [id, data] of Object.entries(prev.actions)) { const userTouched = selectedActionIds.has(id) || rejectedActionIds.has(id) || manuallyAddedIds.has(id) || data.is_manual; const isSuggested = suggestedByRecommenderIds.has(id); if (userTouched || !isSuggested) filtered[id] = data; } return { ...prev, actions: filtered, active_model: undefined }; }); actionsHook.setSuggestedByRecommenderIds(prev => { const next = new Set(); for (const id of prev) { if (selectedActionIds.has(id) || rejectedActionIds.has(id) || manuallyAddedIds.has(id)) next.add(id); } return next; }); analysis.setPendingAnalysisResult(null); }, [setResult, actionsHook, analysis, selectedActionIds, rejectedActionIds, manuallyAddedIds, suggestedByRecommenderIds]); // The Clear button opens a confirmation dialog first (reusing the // shared template) so the operator sees // exactly what is removed and what is kept before committing. const requestClearSuggested = useCallback(() => { setConfirmDialog({ type: 'clearSuggested' }); }, []); const wrappedDisplayPrioritized = useCallback( () => analysis.handleDisplayPrioritizedActions(selectedActionIds, diagrams.setActiveTab), [analysis, selectedActionIds, diagrams.setActiveTab] ); const wrappedAssetClick = useCallback( (actionId: string, assetName: string, tab: 'action' | 'n' | 'contingency' = 'action') => diagrams.handleAssetClick(actionId, assetName, tab, diagrams.selectedActionId, wrappedActionSelect), [diagrams, wrappedActionSelect] ); // Zoom the currently-active diagram tab on a named asset without // switching tabs. Used by the sticky Contingency and Overloads // sections: operators want to keep the view they're on (N / N-1 / // Action) and just focus the clicked line in place. const handleZoomOnActiveTab = useCallback((assetName: string) => { if (!assetName) return; const tab = diagrams.activeTab; if (tab === 'overflow') return; interactionLogger.record('asset_clicked', { action_id: '', asset_name: assetName, tab }); // Update inspectQuery (so the inspect overlay, if open, reflects // the focus) AND call zoomToElement directly — the auto-zoom effect // skips no-op query changes, whereas we want re-clicking the same // line to re-center the view. diagrams.setInspectQueryForTab(tab, assetName); diagrams.zoomToElement(assetName, tab); }, [diagrams]); const saveParams = useMemo(() => ({ networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, selectedBranch: selectedContingency.join('+'), selectedContingency, selectedOverloads, monitorDeselected, committedAdditionalLinesToCut, nOverloads: nDiagram?.lines_overloaded ?? [], n1Overloads: n1Diagram?.lines_overloaded ?? [], nOverloadsRho: nDiagram?.lines_overloaded_rho, n1OverloadsRho: n1Diagram?.lines_overloaded_rho, result, selectedActionIds, rejectedActionIds, manuallyAddedIds, suggestedByRecommenderIds, setError, }), [ networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, selectedContingency, selectedOverloads, monitorDeselected, committedAdditionalLinesToCut, nDiagram, n1Diagram, result, selectedActionIds, rejectedActionIds, manuallyAddedIds, suggestedByRecommenderIds, setError, ]); const wrappedSaveResults = useCallback( () => session.handleSaveResults(saveParams), [session, saveParams] ); const wrappedOpenReloadModal = useCallback( () => session.handleOpenReloadModal(outputFolderPath, setError), [session, outputFolderPath, setError] ); const restoreContext = useMemo(() => ({ outputFolderPath, setNetworkPath, setActionPath, setLayoutPath, setMinLineReconnections, setMinCloseCoupling, setMinOpenCoupling, setMinLineDisconnections, setMinPst, setMinLoadShedding, setMinRenewableCurtailmentActions, setMinRedispatch, setAllowedActionTypes, setNPrioritizedActions, setLinesMonitoringPath, setMonitoringFactor, setPreExistingOverloadThreshold, setIgnoreReconnections, setPypowsyblFastMode, setMonitorDeselected: analysis.setMonitorDeselected, setSelectedOverloads: analysis.setSelectedOverloads, setCommittedAdditionalLinesToCut: analysis.setCommittedAdditionalLinesToCut, setResult, setSelectedActionIds: actionsHook.setSelectedActionIds, setRejectedActionIds: actionsHook.setRejectedActionIds, setManuallyAddedIds: actionsHook.setManuallyAddedIds, setSuggestedByRecommenderIds: actionsHook.setSuggestedByRecommenderIds, setSelectedContingency, setPendingContingency, resetAllState, restoringSessionRef: diagrams.restoringSessionRef, committedBranchRef: diagrams.committedBranchRef, committedNetworkPathRef, setError, applyConfigResponse, setBranches, setVoltageLevels, setNameMap, setNominalVoltageMap: diagrams.setNominalVoltageMap, setUniqueVoltages: diagrams.setUniqueVoltages, fetchBaseDiagram: diagrams.fetchBaseDiagram, ingestBaseDiagram: diagrams.ingestBaseDiagram, setVoltageRange: diagrams.setVoltageRange, }), [ outputFolderPath, setNetworkPath, setActionPath, setLayoutPath, setMinLineReconnections, setMinCloseCoupling, setMinOpenCoupling, setMinLineDisconnections, setMinPst, setMinLoadShedding, setMinRenewableCurtailmentActions, setMinRedispatch, setAllowedActionTypes, setNPrioritizedActions, setLinesMonitoringPath, setMonitoringFactor, setPreExistingOverloadThreshold, setIgnoreReconnections, setPypowsyblFastMode, analysis, actionsHook, setResult, setSelectedContingency, setPendingContingency, resetAllState, diagrams, setError, applyConfigResponse, setBranches, setVoltageLevels, setNameMap, ]); const wrappedRestoreSession = useCallback( (sessionName: string) => session.handleRestoreSession(sessionName, restoreContext), [session, restoreContext] ); // Check if there is any analysis state that would be lost on contingency change const hasAnalysisState = useCallback(() => { return !!(result || pendingAnalysisResult || selectedActionId || actionDiagram || manuallyAddedIds.size > 0 || selectedActionIds.size > 0 || rejectedActionIds.size > 0); }, [result, pendingAnalysisResult, selectedActionId, actionDiagram, manuallyAddedIds, selectedActionIds, rejectedActionIds]); // Sidebar-banner Clear button: drops the current contingency and // returns to the picker. Routes through the contingency confirmation // dialog when analysis state would otherwise be lost; clears // directly otherwise (mirroring the picker-driven flow's gating). const requestClearContingency = useCallback(() => { interactionLogger.record('contingency_clear_requested', { had_analysis_state: hasAnalysisState(), }); if (hasAnalysisState()) { setConfirmDialog({ type: 'contingency', pendingBranch: '' }); return; } clearContingencyState(); setSelectedContingency([]); setPendingContingency([]); }, [hasAnalysisState, clearContingencyState]); // Full-fidelity snapshot of every parameter an agent would need to // replay a config-loaded / settings-applied gesture. Per the // interaction-logging replay contract each event must carry ALL // inputs — "click Load Study" alone is not enough, the agent has // to know which paths and recommender thresholds to type in first. const buildConfigInteractionDetails = useCallback((): Record => ({ network_path: networkPath, action_file_path: actionPath, layout_path: layoutPath, output_folder_path: outputFolderPath, min_line_reconnections: minLineReconnections, min_close_coupling: minCloseCoupling, min_open_coupling: minOpenCoupling, min_line_disconnections: minLineDisconnections, min_pst: minPst, min_load_shedding: minLoadShedding, min_renewable_curtailment_actions: minRenewableCurtailmentActions, min_redispatch: minRedispatch, allowed_action_types: allowedActionTypes, n_prioritized_actions: nPrioritizedActions, lines_monitoring_path: linesMonitoringPath, monitoring_factor: monitoringFactor, pre_existing_overload_threshold: preExistingOverloadThreshold, ignore_reconnections: ignoreReconnections, pypowsybl_fast_mode: pypowsyblFastMode, }), [ networkPath, actionPath, layoutPath, outputFolderPath, minLineReconnections, minCloseCoupling, minOpenCoupling, minLineDisconnections, minPst, minLoadShedding, minRenewableCurtailmentActions, minRedispatch, allowedActionTypes, nPrioritizedActions, linesMonitoringPath, monitoringFactor, preExistingOverloadThreshold, ignoreReconnections, pypowsyblFastMode, ]); const applySettingsImmediate = useCallback(async () => { // settings_applied carries the full settings payload so a replay // agent can populate every field before clicking Apply. It's // treated as a wait-point by consumers of the log: the next // action must wait until the network reload (network, branches, // voltage levels) has finished. interactionLogger.record('settings_applied', buildConfigInteractionDetails()); try { resetAllState(); if (!networkPath || !actionPath) { setSettingsBackup(createCurrentBackup()); setIsSettingsOpen(false); return; } // If the config file path changed, load the new file FIRST and use // the resolved `UserConfig` directly to drive `api.updateConfig` — // the React state queued by `applyLoadedConfig` inside // `changeConfigFilePath` has not flushed yet, so reading via // `buildConfigRequest()` here would replay the previous render's // values and silently send the OLD config to the backend (which // the auto-save effect would then persist back into the loaded // file, undoing the operator's selection — see the regression // test in `configUpload.repro.test.tsx`, fixed 2026-05-08). let freshlyLoadedCfg: import('./api').UserConfig | null = null; if (configFilePath && configFilePath !== lastActiveConfigFilePath) { freshlyLoadedCfg = await changeConfigFilePath(configFilePath); } const configRequest = freshlyLoadedCfg ? configRequestFromUserConfig(freshlyLoadedCfg) : buildConfigRequest(); const configRes = await api.updateConfig(configRequest); applyConfigResponse(configRes as Record); // Fire the 4 post-config XHRs in parallel. The base-diagram call is // the slowest (~6-7s pypowsybl NAD on large grids) and previously // only started after branches resolved — wasting the ~0.8s branches // gap off the critical path of the initial load. // See docs/performance/history/loading-parallel.md. const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ api.getBranches(), api.getVoltageLevels(), api.getNominalVoltages(), api.getNetworkDiagram(), // Cheap query (~1 ms even on PyPSA-EUR France); pulled in // parallel so it never extends the critical path. Used to // anchor overflow-graph action pins on substations. api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record })), ]); setBranches(branchRes.branches); setVoltageLevels(vlRes.voltage_levels); // Merge element + VL name maps into a single lookup setNameMap({ ...branchRes.name_map, ...vlRes.name_map }); setSelectedContingency([]); setPendingContingency([]); diagrams.setNominalVoltageMap(nomVRes.mapping); diagrams.setUniqueVoltages(nomVRes.unique_kv); if (nomVRes.unique_kv.length > 0) { diagrams.setVoltageRange([nomVRes.unique_kv[0], nomVRes.unique_kv[nomVRes.unique_kv.length - 1]]); } diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); setVlToSubstation(vlSubRes.mapping || {}); committedNetworkPathRef.current = networkPath; interactionLogger.record('config_loaded', buildConfigInteractionDetails()); setSettingsBackup(createCurrentBackup()); setIsSettingsOpen(false); } catch (err: unknown) { const e = err as { response?: { data?: { detail?: string } }; message?: string }; setError('Failed to apply settings: ' + apiErrorMessage(e)); } }, [networkPath, actionPath, buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, createCurrentBackup, setError, setSettingsBackup, setIsSettingsOpen, diagrams, configFilePath, lastActiveConfigFilePath, changeConfigFilePath, resetAllState, buildConfigInteractionDetails]); // Apply Settings entry point used by the Settings modal. If a study // is already loaded — whether or not analysis has been run yet — we // route through the same confirmation dialog as the "Load Study" // button. Applying any settings (in particular changing the config // file path) silently reloads the network and drops the in-flight // study, so the user must be warned even when only a base network // is loaded with no analysis state. const handleApplySettingsClick = useCallback(() => { if (hasAnalysisState() || committedNetworkPathRef.current) { setConfirmDialog({ type: 'applySettings' }); return; } applySettingsImmediate(); }, [hasAnalysisState, applySettingsImmediate]); const handleLoadConfig = useCallback(async () => { setConfigLoading(true); resetAllState(); try { // Same stale-closure trap as applySettingsImmediate — see the long // comment there. After `changeConfigFilePath` resolves, the fresh // `UserConfig` it returns is the source of truth for // `api.updateConfig`; `buildConfigRequest()` would silently replay // the previous render's React state. let freshlyLoadedCfg: import('./api').UserConfig | null = null; if (configFilePath && configFilePath !== lastActiveConfigFilePath) { freshlyLoadedCfg = await changeConfigFilePath(configFilePath); } const configRequest = freshlyLoadedCfg ? configRequestFromUserConfig(freshlyLoadedCfg) : buildConfigRequest(); const configRes = await api.updateConfig(configRequest); applyConfigResponse(configRes as Record); // See the sibling call site in `applySettingsImmediate` for context: // fire 4 XHRs in parallel so the slow base-diagram call overlaps // with branches/voltage-levels/nominal-voltages. const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ api.getBranches(), api.getVoltageLevels(), api.getNominalVoltages(), api.getNetworkDiagram(), // Cheap query (~1 ms even on PyPSA-EUR France); pulled in // parallel so it never extends the critical path. Used to // anchor overflow-graph action pins on substations. api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record })), ]); setBranches(branchRes.branches); setVoltageLevels(vlRes.voltage_levels); setNameMap({ ...branchRes.name_map, ...vlRes.name_map }); setSelectedContingency([]); setPendingContingency([]); diagrams.setNominalVoltageMap(nomVRes.mapping); diagrams.setUniqueVoltages(nomVRes.unique_kv); if (nomVRes.unique_kv.length > 0) { diagrams.setVoltageRange([nomVRes.unique_kv[0], nomVRes.unique_kv[nomVRes.unique_kv.length - 1]]); } diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); setVlToSubstation(vlSubRes.mapping || {}); committedNetworkPathRef.current = networkPath; interactionLogger.record('config_loaded', buildConfigInteractionDetails()); } catch (err: unknown) { const e = err as { response?: { data?: { detail?: string } }; message?: string }; setError('Failed to load config: ' + apiErrorMessage(e)); } finally { setConfigLoading(false); } }, [buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, setError, diagrams, networkPath, configFilePath, lastActiveConfigFilePath, changeConfigFilePath, resetAllState, buildConfigInteractionDetails]); const handleLoadStudyClick = useCallback(() => { if (hasAnalysisState()) { setConfirmDialog({ type: 'loadStudy' }); } else { handleLoadConfig(); } }, [hasAnalysisState, handleLoadConfig]); // Network path commit pipeline used by the Header (file picker AND // input blur). When a study is already loaded and the path is being // changed to a different value, prompt for confirmation before // silently dropping the in-flight study. The setNetworkPath call is // optimistic — it makes the input immediately reflect the new path // even while the dialog is open — and is reverted by // handleCancelDialog if the user backs out. const requestNetworkPathChange = useCallback((newPath: string) => { setNetworkPath(newPath); const trimmed = newPath.trim(); if (!trimmed) return; if (trimmed === committedNetworkPathRef.current) return; // Only warn once a study has actually been loaded — initial path // entry on an empty session must not trigger the dialog. if (!committedNetworkPathRef.current) return; setConfirmDialog({ type: 'changeNetwork', pendingNetworkPath: trimmed }); }, [setNetworkPath]); const handleConfirmDialog = useCallback(() => { if (!confirmDialog) return; // `clearSuggested` is not a study-reset gesture — it keeps the // network, the contingency, and the operator's decisions. It logs // its own `suggested_actions_cleared` event inside // `performClearSuggested`, so skip the `contingency_confirmed` log. if (confirmDialog.type === 'clearSuggested') { performClearSuggested(); setConfirmDialog(null); return; } interactionLogger.record('contingency_confirmed', { type: confirmDialog.type, pending_branch: confirmDialog.pendingBranch }); if (confirmDialog.type === 'contingency') { clearContingencyState(); const pending = confirmDialog.pendingBranch ? confirmDialog.pendingBranch.split('+').filter(Boolean) : []; setSelectedContingency(pending); setPendingContingency(pending); } else if (confirmDialog.type === 'applySettings') { applySettingsImmediate(); } else if (confirmDialog.type === 'changeNetwork') { // pendingNetworkPath was already setNetworkPath'd by // requestNetworkPathChange. Reload the config so the backend // picks up the new file. handleLoadConfig(); } else { handleLoadConfig(); } setConfirmDialog(null); }, [confirmDialog, clearContingencyState, handleLoadConfig, applySettingsImmediate, performClearSuggested]); // ===== App-Level Effects ===== useEffect(() => { diagrams.selectedContingencyForSld.current = selectedContingency; }, [selectedContingency, diagrams.selectedContingencyForSld]); useContingencyFetch({ selectedContingency, branches, voltageLevelsLength: voltageLevels.length, diagrams, analysisLoading, hasAnalysisState, clearContingencyState, setSelectedContingency, setConfirmDialog, setError, }); // ===== Game Mode integration ===== // Drives the workspace from the Game shell (load a study's network + // contingency) and publishes the physical result back so the shell can // score it. The whole block is inert unless launched with `?game=1`. // Load a study: swap network + action catalogue, then arm its contingency. // Mirrors handleLoadConfig but builds the config request explicitly from // the study so it doesn't depend on not-yet-flushed settings state. const loadGameStudy = useCallback(async (study: GameStudy) => { setConfigLoading(true); resetAllState(); // Reflect the study paths in Settings so save/persist stays coherent. setNetworkPath(study.networkPath); setActionPath(study.actionFilePath); if (study.layoutPath !== undefined) setLayoutPath(study.layoutPath); if (study.linesMonitoringPath !== undefined) setLinesMonitoringPath(study.linesMonitoringPath); try { // Recommender settings (per-type minima, model, monitoring factor, …) // must come from the active persisted environment config, NOT the // in-memory `useSettings` state. GameShell mounts and fires this // loader before useSettings' async `getUserConfig()` effect has resolved, // so `buildConfigRequest()` here would replay the hardcoded defaults // (min_redispatch=0, n_prioritized_actions=10, allowed_action_types=[], …) // and the environment's real config (e.g. min_redispatch=2) would be // silently dropped — the same stale-closure trap `handleLoadConfig` // guards against. The study only dictates the network / action / layout // PATHS + contingency; everything else comes from the env config. const freshCfg = await api.getUserConfig().catch(() => null); const baseConfig = freshCfg ? configRequestFromUserConfig(freshCfg) : buildConfigRequest(); const configRequest = { ...baseConfig, network_path: study.networkPath, action_file_path: study.actionFilePath, layout_path: study.layoutPath ?? '', ...(study.linesMonitoringPath !== undefined ? { lines_monitoring_path: study.linesMonitoringPath } : {}), }; const configRes = await api.updateConfig(configRequest); applyConfigResponse(configRes as Record); const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([ api.getBranches(), api.getVoltageLevels(), api.getNominalVoltages(), api.getNetworkDiagram(), api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record })), ]); setBranches(branchRes.branches); setVoltageLevels(vlRes.voltage_levels); setNameMap({ ...branchRes.name_map, ...vlRes.name_map }); setPendingContingency([]); diagrams.setNominalVoltageMap(nomVRes.mapping); diagrams.setUniqueVoltages(nomVRes.unique_kv); if (nomVRes.unique_kv.length > 0) { diagrams.setVoltageRange([nomVRes.unique_kv[0], nomVRes.unique_kv[nomVRes.unique_kv.length - 1]]); } diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length); setVlToSubstation(vlSubRes.mapping || {}); committedNetworkPathRef.current = study.networkPath; interactionLogger.record('config_loaded', buildConfigInteractionDetails()); // Arm the contingency — useContingencyFetch picks this up and fetches // the N-1 diagram (no analysis state yet, so no confirm dialog). if (study.contingencyElementId) { setSelectedContingency([study.contingencyElementId]); } } catch (err: unknown) { const e = err as { response?: { data?: { detail?: string } }; message?: string }; const msg = 'Failed to load study: ' + apiErrorMessage(e); setError(msg); throw new Error(msg); } finally { setConfigLoading(false); } }, [ buildConfigRequest, configRequestFromUserConfig, applyConfigResponse, resetAllState, diagrams, setNetworkPath, setActionPath, setLayoutPath, setLinesMonitoringPath, setBranches, setVoltageLevels, setNameMap, setPendingContingency, setVlToSubstation, setSelectedContingency, setConfigLoading, setError, buildConfigInteractionDetails, ]); useEffect(() => { if (!gameBridge.isGameMode()) return; gameBridge.registerLoader(loadGameStudy); }, [loadGameStudy]); // Publish the physical result of the current study to the Game shell. useEffect(() => { if (!gameBridge.isGameMode()) return; const chosenActions = [...selectedActionIds].map((id) => { const d = result?.actions[id]; const maxRho = d?.max_rho ?? null; const after = d?.lines_overloaded_after; const solved = maxRho != null && maxRho < 1.0 && (!after || after.length === 0); return { actionId: id, description: d?.description_unitaire, maxRho, linesOverloadedAfter: after, solved, }; }); const rhoArr = n1Diagram?.lines_overloaded_rho; const baselineMaxRho = rhoArr && rhoArr.length ? Math.max(...rhoArr) : null; gameBridge.publishSnapshot({ contingencyElementIds: selectedContingency, baselineMaxRho, chosenActions, }); }, [result, selectedActionIds, selectedContingency, n1Diagram]); // Re-seed selectedOverloads with the full N-1 overload list only when a // new n1Diagram is loaded. Comparing against the live selectedOverloads // would clobber user-initiated double-click unselects: the analysis memo // refreshes on every toggle, retriggering this effect and re-adding the // overload the user just removed. const prevN1DiagramRef = useRef(null); useEffect(() => { if (prevN1DiagramRef.current === n1Diagram) return; prevN1DiagramRef.current = n1Diagram; const nextSet = n1Diagram?.lines_overloaded ? new Set(n1Diagram.lines_overloaded) : new Set(); analysis.setSelectedOverloads(nextSet); }, [n1Diagram, analysisLoading, n1Loading, analysis]); const { viewModeForTab, handleViewModeChangeForTab } = useDiagramHighlights({ diagrams, result, selectedContingency, selectedOverloads, monitoringFactor, detachedTabs, }); // ===== Extracted JSX callbacks (stable references for React.memo) ===== /** * Replace the pending list with whatever the multi-select dropdown * currently shows. We diff against the previous pending list to * emit a single ``contingency_element_added`` / * ``contingency_element_removed`` event per change so the * interaction log stays replay-friendly even when the user picks * several elements before pressing Apply. */ const handlePendingContingencyChange = useCallback((next: string[]) => { const prev = pendingContingency; const prevSet = new Set(prev); const nextSet = new Set(next); for (const id of next) { if (!prevSet.has(id)) interactionLogger.record('contingency_element_added', { element: id }); } for (const id of prev) { if (!nextSet.has(id)) interactionLogger.record('contingency_element_removed', { element: id }); } setPendingContingency(next); }, [pendingContingency]); /** * Commit the pending list. Triggers the contingency-state confirm * dialog when an analysis already exists (same routing as today's * single-element flow). */ const handleContingencyApply = useCallback(() => { const next = [...pendingContingency]; interactionLogger.record('contingency_applied', { elements: next }); setSelectedContingency(next); }, [pendingContingency]); const handleDismissWarning = useCallback(() => { setShowMonitoringWarning(false); }, [setShowMonitoringWarning]); const handleOpenConfigSettings = useCallback(() => { setIsSettingsOpen(true); setSettingsTab('configurations'); }, [setIsSettingsOpen, setSettingsTab]); const handleToggleMonitorDeselected = useCallback(() => { analysis.setMonitorDeselected(!analysis.monitorDeselected); }, [analysis]); const handleTabChange = useCallback((tab: TabId) => { interactionLogger.record('diagram_tab_changed', { tab }); diagrams.setActiveTab(tab); }, [diagrams]); const handleVoltageRangeChange = useCallback((range: [number, number]) => { interactionLogger.record('voltage_range_changed', { min: range[0], max: range[1] }); diagrams.setVoltageRange(range); }, [diagrams]); const handleInspectQueryChange = useCallback((q: string) => { interactionLogger.record('inspect_query_changed', { query: q }); diagrams.setInspectQuery(q); }, [diagrams]); const handleToggleVoltageLevelNames = useCallback((show: boolean) => { interactionLogger.record('vl_names_toggled', { show }); setShowVoltageLevelNames(show); }, [setShowVoltageLevelNames]); // Per-tab inspect variant. Lets a detached tab's overlay zoom its // own tab rather than the main-window activeTab — see // useDiagrams.setInspectQueryForTab for the full story. const handleInspectQueryChangeFor = useCallback((targetTab: TabId, q: string) => { interactionLogger.record('inspect_query_changed', { query: q, target_tab: targetTab }); diagrams.setInspectQueryForTab(targetTab, q); }, [diagrams]); const handleVlOpen = useCallback((vlName: string) => { // Always carry the currently-selected action id into the SLD // overlay — NOT just when activeTab === 'action'. The SLD's // internal sub-tab buttons let the user switch to the "action" // sub-tab from any tab, and if we open the overlay with an // empty actionId the backend rejects the switch with // "Action '' not found in last analysis result". handleVlDoubleClick(selectedActionId || '', vlName); }, [handleVlDoubleClick, selectedActionId]); // Clicking a (relabelled) feeder name on the SLD jumps to the far-end VL's // SLD, keeping the current sub-tab so the same contingency / overload stays // in view from the other extremity. const handleSldNavigateToVl = useCallback((vlId: string) => { if (!vlOverlay) return; handleVlDoubleClick(vlOverlay.actionId || selectedActionId || '', vlId, vlOverlay.tab); }, [vlOverlay, handleVlDoubleClick, selectedActionId]); // Keep the VL-disk interaction callbacks in refs so the delegated // listeners below re-bind ONLY when a diagram / its metadata actually // changes — never on an unrelated App render, which would needlessly // tear down the listeners and (worse) cancel an in-flight single-click // timer mid-window. const vlSelectRef = useRef(handleInspectQueryChangeFor); const vlOpenSldRef = useRef(handleVlOpen); const vlDisplayNameRef = useRef(displayName); useEffect(() => { vlSelectRef.current = handleInspectQueryChangeFor; vlOpenSldRef.current = handleVlOpen; vlDisplayNameRef.current = displayName; }); // Voltage-level disk interactions on every NAD tab: hover shows the VL // name while the static labels are hidden, single-click selects the VL // (drives the Inspect field + auto-zoom), double-click opens its SLD. // Delegated listeners only — see `attachVlInteractions` for the // performance contract (no per-node / per-frame work; idle during // pan/zoom gestures because `.svg-interacting` disables SVG hit- // testing). Re-binds only on a diagram / metadata-index refresh. useEffect(() => { const targets: Array = [ [nSvgContainerRef.current, diagrams.nMetaIndex, 'n'], [n1SvgContainerRef.current, diagrams.n1MetaIndex, 'contingency'], [actionSvgContainerRef.current, diagrams.actionMetaIndex, 'action'], ]; const teardowns = targets.map(([container, metaIndex, tab]) => attachVlInteractions(container, metaIndex, { displayName: (id) => vlDisplayNameRef.current(id), onSelect: (vlId) => vlSelectRef.current(tab, vlId), onOpenSld: (vlId) => vlOpenSldRef.current(vlId), }), ); return () => teardowns.forEach((teardown) => teardown()); }, [ nDiagram, n1Diagram, actionDiagram, diagrams.nMetaIndex, diagrams.n1MetaIndex, diagrams.actionMetaIndex, nSvgContainerRef, n1SvgContainerRef, actionSvgContainerRef, ]); // Double-click on an action pin in the overflow graph drills into // that substation's SLD on the post-action ('action') sub-tab. // Guarded on the action being known to the analysis result — // double-clicks on stale or unknown pins are silently ignored. const handleOverflowPinDoubleClick = useCallback((actionId: string, substation: string) => { if (!actionId || !substation) return; const knownAction = !!result?.actions?.[actionId]; if (!knownAction) return; interactionLogger.record('overflow_pin_double_clicked', { actionId, substation, }); handleVlDoubleClick(actionId, substation, 'action'); }, [handleVlDoubleClick, result?.actions]); const handleCancelDialog = useCallback(() => { // Cancelling a "Change Network?" dialog must roll back the // optimistic networkPath update done by requestNetworkPathChange, // otherwise the Header field would silently diverge from the // currently-loaded study's path. if (confirmDialog?.type === 'changeNetwork') { setNetworkPath(committedNetworkPathRef.current); } setConfirmDialog(null); }, [confirmDialog, setNetworkPath]); // ===== Tiered notice system ===== // The previous UI stacked up to five concurrent yellow banners in // the sidebar. They now feed a single "Notices" pill at the top // of the sidebar. Each notice owns its dismissal state via the // existing showXxxWarning state (kept where it lived to preserve // the reset-on-apply-settings behaviour). const [showActionDictNotice, setShowActionDictNotice] = useState(true); const [showRecommenderNotice, setShowRecommenderNotice] = useState(true); // Re-arm the notices whenever a fresh study is loaded — same // semantics as the local state ActionFeed used to own. useEffect(() => { setShowActionDictNotice(true); setShowRecommenderNotice(true); }, [networkPath, actionPath]); const sidebarNotices = useMemo(() => { const list: Notice[] = []; // Action-dictionary info — shown until the operator dismisses // it manually. The previous "auto-clear once an action has been // simulated" rule was removed: notices live in the discrete // sidebar pill that no longer overloads the main window // visually, so the operator should decide when each notice has // been read — not the lifecycle of the analysis. if (showActionDictNotice && actionDictFileName && actionDictStats) { list.push({ id: 'action-dict', severity: 'info', title: 'Action dictionary', body: ( <> {actionDictFileName}
🔄 Reco: {actionDictStats.reco} ⛔ Disco: {actionDictStats.disco} 📐 PST: {actionDictStats.pst} 🔓 Open coupling: {actionDictStats.open_coupling} 🔒 Close coupling: {actionDictStats.close_coupling}
), action: { label: 'Change in settings', onClick: () => handleOpenSettings('paths') }, onDismiss: () => setShowActionDictNotice(false), }); } // Monitoring coverage warning — surfaces the reduced monitoring // scope before the operator runs an analysis. if (showMonitoringWarning && totalLinesCount && totalLinesCount > 0) { const monitored = monitoredLinesCount || 0; list.push({ id: 'monitoring-coverage', severity: 'warning', title: 'Monitoring coverage', body: ( <> {monitored} of {totalLinesCount} lines monitored {' '}({totalLinesCount - monitored} without permanent limits). Monitoring factor: {' '}{Math.round((monitoringFactor || 0.95) * 100)}%, pre-existing overload threshold: {' '}{Math.round((preExistingOverloadThreshold || 0.02) * 100)}%. ), action: { label: 'Change in settings', onClick: handleOpenConfigSettings }, onDismiss: handleDismissWarning, }); } // Recommender thresholds — shown until the operator dismisses // it manually. The previous "auto-hide once analysis kicks off" // rule was removed for consistency with the action-dict notice: // the sidebar pill is opt-in and doesn't compete for attention // with the main window, so the operator owns the dismiss // gesture. if (showRecommenderNotice) { list.push({ id: 'recommender-thresholds', severity: 'info', title: 'Recommender thresholds', body: ( <>
• Minimum actions: {recommenderConfig.minLineReconnections} reco, {recommenderConfig.minCloseCoupling} close, {recommenderConfig.minOpenCoupling} open, {recommenderConfig.minLineDisconnections} disco, {recommenderConfig.minPst} PST, {recommenderConfig.minLoadShedding} load shedding, {recommenderConfig.minRenewableCurtailmentActions} RC, {recommenderConfig.minRedispatch} redispatch
• Maximum suggestions: {recommenderConfig.nPrioritizedActions}
• Ignore reconnections: {recommenderConfig.ignoreReconnections ? 'Yes' : 'No'}
), action: { label: 'Change in settings', onClick: () => handleOpenSettings('recommender') }, onDismiss: () => setShowRecommenderNotice(false), }); } // Additional "lines to prevent flow increase" — two complementary // notices, one per analysis lifecycle phase: // // PRE-RUN (no result yet, picker non-empty): warning that // surfaces the EXTRA targets the next Analyze & Suggest run // is about to pass to the recommender. // POST-RUN (result present, committed snapshot non-empty): // info that surfaces the EXTRA targets baked into the // CURRENT result, so the operator never loses track of the // hypothesis the recommendations were computed against — // even if the picker has since been edited and a new run is // pending. The committed snapshot is taken inside // ``useAnalysis`` at the moment Step 2 was posted. if (additionalLinesToCut.size > 0 && !pendingAnalysisResult && !result) { const lines = Array.from(additionalLinesToCut); list.push({ id: 'additional-lines-to-cut', severity: 'warning', title: 'Additional lines to prevent flow increase', body: ( <>
The next Analyze & Suggest run will treat{' '} {lines.length} extra line{lines.length === 1 ? '' : 's'} as {' '}targets to prevent flow increase on (simulated as disconnected, not rendered as overloads):
{lines.map(displayName).join(', ')}
), }); } if (committedAdditionalLinesToCut.size > 0 && !!result) { const lines = Array.from(committedAdditionalLinesToCut); list.push({ id: 'additional-lines-to-cut-committed', severity: 'info', title: 'Additional lines integrated in overflow analysis', body: ( <>
{lines.length} additional line {lines.length === 1 ? '' : 's'} integrated in overflow analysis on which to prevent powerflow increase:
{lines.map(displayName).join(', ')}
), }); } return list; }, [ showActionDictNotice, actionDictFileName, actionDictStats, result, showMonitoringWarning, monitoredLinesCount, totalLinesCount, monitoringFactor, preExistingOverloadThreshold, showRecommenderNotice, pendingAnalysisResult, recommenderConfig, handleOpenSettings, handleOpenConfigSettings, handleDismissWarning, additionalLinesToCut, committedAdditionalLinesToCut, displayName, ]); return (
{/* Settings Modal */}
void} onToggleOverload={analysis.handleToggleOverload} monitorDeselected={monitorDeselected} onToggleMonitorDeselected={handleToggleMonitorDeselected} monitoringHint={ showMonitoringWarning && totalLinesCount && totalLinesCount > 0 ? `${monitoredLinesCount || 0}/${totalLinesCount} lines monitored — see Notices for details.` : null } onClearContingency={requestClearContingency} hideContingencyPicker={sidebarSwitchedToFeed} collapsed={sidebarCollapsed} onToggleCollapsed={handleToggleSidebarCollapsed} overviewFilters={overviewFilters} onOverviewFiltersChange={setOverviewFilters} hasActions={Object.keys(result?.actions || {}).length > 0} > {sidebarSwitchedToFeed && result?.antenna_meta && ( )} {sidebarSwitchedToFeed && ( `` strings that ``displayName`` cannot resolve. linesOverloaded={ result?.lines_overloaded && result.lines_overloaded.length > 0 ? result.lines_overloaded : (n1Diagram?.lines_overloaded || []) } selectedActionId={selectedActionId} scrollTarget={scrollTarget} selectedActionIds={selectedActionIds} rejectedActionIds={rejectedActionIds} manuallyAddedIds={manuallyAddedIds} combinedActions={result?.combined_actions ?? null} pendingAnalysisResult={pendingAnalysisResult} onDisplayPrioritizedActions={wrappedDisplayPrioritized} onRunAnalysis={wrappedRunAnalysis} onCancelAnalysis={analysis.cancelAnalysis} canRunAnalysis={selectedContingency.length > 0 && !analysisLoading} onActionSelect={wrappedActionSelect} onActionFavorite={wrappedActionFavorite} onActionReject={actionsHook.handleActionReject} onAssetClick={wrappedAssetClick} nodesByEquipmentId={diagrams.nMetaIndex?.nodesByEquipmentId ?? null} edgesByEquipmentId={diagrams.nMetaIndex?.edgesByEquipmentId ?? null} disconnectedElement={selectedContingency} onManualActionAdded={wrappedManualActionAdded} onActionResimulated={wrappedActionResimulated} analysisLoading={analysisLoading} monitoringFactor={monitoringFactor} onVlDoubleClick={handleVlDoubleClick} onUpdateCombinedEstimation={handleUpdateCombinedEstimation} displayName={displayName} onActionDiagramPrimed={diagrams.primeActionDiagram} voltageLevelsLength={voltageLevels.length} overviewFilters={overviewFilters} onOverviewFiltersChange={setOverviewFilters} additionalLines={{ branches: branches, additionalLinesToCut: additionalLinesToCut, onToggleAdditionalLineToCut: analysis.handleToggleAdditionalLineToCut, n1Overloads: n1Diagram?.lines_overloaded || [], }} modelSelector={{ recommenderModel: recommenderModel, setRecommenderModel: setRecommenderModel, availableModels: availableModels, activeModelLabel: result?.active_model ? (availableModels?.find(m => m.name === result.active_model)?.label || result.active_model) : null, }} timing={{ overflowGraphTime: result?.overflow_graph_time ?? null, actionPredictionTime: result?.action_prediction_time ?? null, assessmentTime: result?.assessment_time ?? null, step1Time: result?.step1_time ?? null, enrichmentTime: result?.enrichment_time ?? null, wallClockTime: result?.wall_clock_time ?? null, }} onClearSuggested={requestClearSuggested} /> )}
0} selectedContingency={selectedContingency} vlOverlay={vlOverlay} onOverlayClose={handleOverlayClose} onOverlaySldTabChange={handleOverlaySldTabChange} sldEdit={{ sldEditMode: sldTopologyEdit.editMode, onSldEditModeChange: sldTopologyEdit.setEditMode, sldEditPendingSwitches: sldTopologyEdit.pendingStates, sldEditPendingChanges: sldTopologyEdit.pendingChanges, onSldSwitchClick: sldTopologyEdit.toggleSwitch, sldEditPendingInjections: sldTopologyEdit.pendingInjections, sldEditInjectionChanges: sldTopologyEdit.injectionChanges, onSldInjectionStage: sldTopologyEdit.setInjection, onSldInjectionRemove: sldTopologyEdit.removeInjection, onSldEditSimulate: handleSimulateSldEdit, onSldEditReset: sldTopologyEdit.reset, sldEditBusy: sldEditBusy, sldEditCombinedWithActionId: sldEditBaseActionId, sldPreviewSvg: sldPreview?.svg ?? null, sldPreviewMetadata: sldPreview?.metadata ?? null, sldPreviewStale: !!sldPreview, sldPreviewLoading: sldPreviewLoading, sldFocusedSwitchId: sldTopologyEdit.focusedSwitchId, onSldSwitchFocus: sldTopologyEdit.setFocusedSwitch, onSldSwitchRemove: sldTopologyEdit.removeSwitch, onSldSwitchRemoveMany: sldTopologyEdit.removeSwitches, onSldNavigateToVl: handleSldNavigateToVl, }} voltageLevels={voltageLevels} onVlOpen={handleVlOpen} networkPath={networkPath} layoutPath={layoutPath} onOpenSettings={handleOpenSettings} detach={{ detachedTabs: detachedTabs, onDetachTab: handleDetachTab, onReattachTab: handleReattachTab, onFocusDetachedTab: focusDetachedTab, isTabTied: isTabTied, onToggleTabTie: toggleTabTie, }} actionOverview={{ n1MetaIndex: diagrams.n1MetaIndex, onActionSelect: wrappedActionSelect, onActionFavorite: wrappedActionFavorite, onActionReject: actionsHook.handleActionReject, selectedActionIds: selectedActionIds, rejectedActionIds: rejectedActionIds, onPinPreview: handlePinPreview, onOverviewPzChange: handleOverviewPzChange, monitoringFactor: monitoringFactor, displayName: displayName, overviewFilters: overviewFilters, onOverviewFiltersChange: setOverviewFilters, sidebarCollapsed: sidebarCollapsed, hasActions: Object.keys(result?.actions || {}).length > 0, unsimulatedActionIds: unsimulatedActionIds, unsimulatedActionInfo: unsimulatedActionInfo, onSimulateUnsimulatedAction: handleSimulateUnsimulatedAction, }} showVoltageLevelNames={showVoltageLevelNames} onToggleVoltageLevelNames={handleToggleVoltageLevelNames} />
{/* Confirmation Dialog for contingency change / load study */}
); } export default App;