github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
84.3 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
import { 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<string[]>([]);
/**
* 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<string[]>([]);
const [branches, setBranches] = useState<string[]>([]);
const [voltageLevels, setVoltageLevels] = useState<string[]>([]);
/** ID → human-readable name for branches (lines + transformers) and VLs. */
const [nameMap, setNameMap] = useState<Record<string, string>>({});
/** 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<Record<string, string>>({});
/** 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<PZInstance | null>(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<ConfirmDialogState>(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<ActionOverviewFilters>(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.<type>.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<string, UnsimulatedActionScoreInfo> };
const simulated = new Set(Object.keys(analysis.result?.actions ?? {}));
const ids: string[] = [];
const info: Record<string, UnsimulatedActionScoreInfo> = {};
const seen = new Set<string>();
for (const [type, rawData] of Object.entries(scores)) {
const data = rawData as {
scores?: Record<string, number>;
mw_start?: Record<string, number | null>;
tap_start?: Record<string, { pst_name: string; tap: number; low_tap: number | null; high_tap: number | null } | null>;
};
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<RecommenderDisplayConfig>(() => ({
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<string, import('./types').ActionDetail> = {};
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<string, import('./types').ActionDetail> = {};
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<string>();
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 <ConfirmationDialog/> 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<string, unknown> => ({
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<string, unknown>);
// 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<string, string> })),
]);
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<string, unknown>);
// 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<string, string> })),
]);
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 <App/> 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<string, unknown>);
const [branchRes, vlRes, nomVRes, diagramRaw, vlSubRes] = await Promise.all([
api.getBranches(),
api.getVoltageLevels(),
api.getNominalVoltages(),
api.getNetworkDiagram(),
api.getVoltageLevelSubstations().catch(() => ({ mapping: {} as Record<string, string> })),
]);
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<typeof n1Diagram>(null);
useEffect(() => {
if (prevN1DiagramRef.current === n1Diagram) return;
prevN1DiagramRef.current = n1Diagram;
const nextSet = n1Diagram?.lines_overloaded ? new Set(n1Diagram.lines_overloaded) : new Set<string>();
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<readonly [HTMLElement | null, MetadataIndex | null, TabId]> = [
[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: (
<>
<code style={{ fontFamily: 'monospace', padding: '0 4px' }}>{actionDictFileName}</code>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '4px' }}>
<span>🔄 Reco: <strong>{actionDictStats.reco}</strong></span>
<span>⛔ Disco: <strong>{actionDictStats.disco}</strong></span>
<span>📐 PST: <strong>{actionDictStats.pst}</strong></span>
<span>🔓 Open coupling: <strong>{actionDictStats.open_coupling}</strong></span>
<span>🔒 Close coupling: <strong>{actionDictStats.close_coupling}</strong></span>
</div>
</>
),
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: (
<>
<strong>{monitored}</strong> of <strong>{totalLinesCount}</strong> 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: (
<>
<div>• 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</div>
<div>• Maximum suggestions: {recommenderConfig.nPrioritizedActions}</div>
<div>• Ignore reconnections: {recommenderConfig.ignoreReconnections ? 'Yes' : 'No'}</div>
</>
),
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: (
<>
<div>
The next Analyze &amp; Suggest run will treat{' '}
<strong>{lines.length}</strong> extra line{lines.length === 1 ? '' : 's'} as
{' '}targets to prevent flow increase on (simulated as disconnected, not rendered as overloads):
</div>
<div style={{ marginTop: 4, wordBreak: 'break-word' }}>
{lines.map(displayName).join(', ')}
</div>
</>
),
});
}
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: (
<>
<div>
<strong>{lines.length}</strong> additional line
{lines.length === 1 ? '' : 's'} integrated in overflow
analysis on which to prevent powerflow increase:
</div>
<div style={{ marginTop: 4, wordBreak: 'break-word' }}>
{lines.map(displayName).join(', ')}
</div>
</>
),
});
}
return list;
}, [
showActionDictNotice, actionDictFileName, actionDictStats, result,
showMonitoringWarning, monitoredLinesCount, totalLinesCount,
monitoringFactor, preExistingOverloadThreshold,
showRecommenderNotice, pendingAnalysisResult, recommenderConfig,
handleOpenSettings, handleOpenConfigSettings, handleDismissWarning,
additionalLinesToCut, committedAdditionalLinesToCut, displayName,
]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<Header
networkPath={networkPath}
setNetworkPath={setNetworkPath}
onCommitNetworkPath={requestNetworkPathChange}
configLoading={configLoading}
result={result}
selectedContingency={selectedContingency}
sessionRestoring={sessionRestoring}
onPickSettingsPath={pickSettingsPath}
onLoadStudy={handleLoadStudyClick}
onSaveResults={wrappedSaveResults}
onOpenReloadModal={wrappedOpenReloadModal}
onOpenSettings={handleOpenSettings}
notices={sidebarNotices}
/>
{/* Settings Modal */}
<SettingsModal settings={settings} onApply={handleApplySettingsClick} />
<ReloadSessionModal
showReloadModal={showReloadModal}
setShowReloadModal={setShowReloadModal}
outputFolderPath={outputFolderPath}
sessionListLoading={sessionListLoading}
sessionList={sessionList}
sessionRestoring={sessionRestoring}
onRestoreSession={wrappedRestoreSession}
/>
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<AppSidebar
selectedContingency={selectedContingency}
pendingContingency={pendingContingency}
branches={branches}
nameMap={nameMap}
n1LinesOverloaded={n1Diagram?.lines_overloaded}
n1LinesOverloadedRho={n1Diagram?.lines_overloaded_rho}
nLinesOverloaded={nDiagram?.lines_overloaded}
nLinesOverloadedRho={nDiagram?.lines_overloaded_rho}
selectedOverloads={selectedOverloads}
onPendingContingencyChange={handlePendingContingencyChange}
onContingencyApply={handleContingencyApply}
displayName={displayName}
onContingencyZoom={handleZoomOnActiveTab}
onOverloadClick={wrappedAssetClick as (actionId: string, assetName: string, tab: 'n' | 'contingency') => 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 && (
<AntennaNotice meta={result.antenna_meta} />
)}
{sidebarSwitchedToFeed && (
<ActionFeed
actions={result?.actions || {}}
actionScores={result?.action_scores}
// Prefer the analysis-result overload list when it carries
// entries (step1 / session reload populate it with the
// pypowsybl-style friendly identifiers the rest of the UI
// is wired against), and fall back to the N-1 diagram's
// authoritative list otherwise. The fallback matters for
// the pre-analysis manual-simulation flow: without it the
// card stack inherits ``simulate_manual_action``'s
// vectorised obs-based namesgrid2op's synthetic
// ``line_<i>`` 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}
/>
)}
</AppSidebar>
<div style={{ flex: 1, background: 'white', display: 'flex', flexDirection: 'column' }}>
<VisualizationPanel
activeTab={activeTab}
configLoading={configLoading}
onTabChange={handleTabChange}
nDiagram={nDiagram}
n1Diagram={n1Diagram}
n1Loading={n1Loading}
actionDiagram={actionDiagram}
actionDiagramLoading={actionDiagramLoading}
selectedActionId={selectedActionId}
result={result}
analysisLoading={analysisLoading}
nSvgContainerRef={nSvgContainerRef}
n1SvgContainerRef={n1SvgContainerRef}
actionSvgContainerRef={actionSvgContainerRef}
uniqueVoltages={uniqueVoltages}
voltageRange={voltageRange}
onVoltageRangeChange={handleVoltageRangeChange}
actionViewMode={actionViewMode}
onViewModeChange={handleViewModeChange}
viewModeForTab={viewModeForTab}
onViewModeChangeForTab={handleViewModeChangeForTab}
overflow={{
overflowLayoutMode: diagrams.overflowLayoutMode,
overflowLayoutLoading: diagrams.overflowLayoutLoading,
onOverflowLayoutChange: wrappedOverflowLayoutChange,
onOverflowPinPreview: handlePinPreview,
onOverflowPinDoubleClick: handleOverflowPinDoubleClick,
overflowPins: allOverflowPins,
overflowPinsEnabled: overflowPinsEnabled,
onOverflowPinsToggle: setOverflowPinsEnabled,
}}
inspectQuery={inspectQuery}
onInspectQueryChange={handleInspectQueryChange}
onInspectQueryChangeFor={handleInspectQueryChangeFor}
inspectableItems={inspectableItems}
onResetView={handleManualReset}
onZoomIn={handleManualZoomIn}
onZoomOut={handleManualZoomOut}
hasBranches={branches.length > 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}
/>
</div>
</div>
{/* Confirmation Dialog for contingency change / load study */}
<ConfirmationDialog
confirmDialog={confirmDialog}
onCancel={handleCancelDialog}
onConfirm={handleConfirmDialog}
/>
<NotificationHost />
</div>
);
}
export default App;