// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, // you can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import React, { useState, useRef, useCallback, useLayoutEffect, type CSSProperties } from 'react'; import type { ActionOverviewFilters } from '../types'; import { colors, radius, space, text } from '../styles/tokens'; import ActionFilterRings from './ActionFilterRings'; interface SidebarSummaryProps { /** Currently APPLIED contingency — list of element IDs disconnected. */ selectedContingency: string[]; n1LinesOverloaded: string[] | undefined; n1LinesOverloadedRho: number[] | undefined; /** N-state pre-existing overloads. Surfaced via the info bubble next to * the Overloads label, since they are no longer shown in a dedicated * OverloadPanel card. */ nLinesOverloaded?: string[]; nLinesOverloadedRho?: number[]; selectedOverloads: Set | null | undefined; displayName: (id: string) => string; onContingencyZoom: (assetName: string) => void; onOverloadClick: (actionId: string, assetName: string, tab: 'n' | 'contingency') => void; /** Toggle an N-1 overload's inclusion in the analysis monitoring set. */ onToggleOverload?: (overload: string) => void; /** Whether deselected overloads are still kept in the monitoring scope. */ monitorDeselected?: boolean; onToggleMonitorDeselected?: () => void; /** Optional one-line monitoring coverage hint (replaces the legacy * OverloadPanel hint when present). */ monitoringHint?: string | null; /** Drop the current contingency and return to the contingency picker. * Triggers the confirmation dialog at the call site. */ onClearContingency?: () => void; /** Shared severity + action-type filters; forwarded to the inline * ActionFilterRings row. */ overviewFilters?: ActionOverviewFilters; onOverviewFiltersChange?: (next: ActionOverviewFilters) => void; /** Whether the action feed has any card to filter right now. */ hasActions?: boolean; } /** * Compact sticky strip at the top of the sidebar that keeps the * clickable fields of interest visible while the rest of the sidebar * scrolls. Shows the selected contingency (with zoom-to shortcut) * and the contingency-state overloaded lines (with per-line * navigation + rho percentages). Rendered only when at least one of * those pieces of state is present. * * Hosts: * - a Clear button next to the Contingency label so the operator * can drop the current contingency and go investigate another * one (confirmation dialog handled by the parent); * - an info bubble next to the Overloads label that opens a small * popover listing N-state pre-existing overloads, letting the * operator deselect N-1 overloads (toggling their inclusion in * the analysis monitoring set) and flip the "monitor deselected" * switch — the former OverloadPanel functionality folded into * the sticky banner. */ export default function SidebarSummary({ selectedContingency, n1LinesOverloaded, n1LinesOverloadedRho, nLinesOverloaded, nLinesOverloadedRho, selectedOverloads, displayName, onContingencyZoom, onOverloadClick, onToggleOverload, monitorDeselected = false, onToggleMonitorDeselected, monitoringHint, onClearContingency, overviewFilters, onOverviewFiltersChange, hasActions, }: SidebarSummaryProps) { const hasOverloads = (n1LinesOverloaded?.length ?? 0) > 0; const hasContingency = selectedContingency.length > 0; const hasFilters = !!hasActions && !!overviewFilters && !!onOverviewFiltersChange; const [popoverOpen, setPopoverOpen] = useState(false); const hideTimerRef = useRef(null); const bubbleRef = useRef(null); const cancelHide = useCallback(() => { if (hideTimerRef.current != null) { window.clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } }, []); const scheduleHide = useCallback(() => { cancelHide(); hideTimerRef.current = window.setTimeout(() => setPopoverOpen(false), 180); }, [cancelHide]); if (!hasContingency && !hasOverloads && !hasFilters) return null; const hasNOverloads = (nLinesOverloaded?.length ?? 0) > 0; const overloadsBubbleEnabled = hasOverloads || hasNOverloads || !!monitoringHint; return (
{hasContingency && (
⚡ Contingency{selectedContingency.length > 1 ? ` (N-${selectedContingency.length})` : ''}: {selectedContingency.map((id, i) => ( {i > 0 && , } ))} {onClearContingency && ( )}
)} {hasOverloads && (
⚠️ Overloads: {n1LinesOverloaded!.map((name, i) => { const rho = n1LinesOverloadedRho?.[i]; const rhoPct = rho != null && !Number.isNaN(rho) ? `${(rho * 100).toFixed(1)}%` : null; const isSelected = selectedOverloads?.has(name) ?? true; return ( {i > 0 && ', '} {rhoPct && ( ({rhoPct}) )} ); })} {overloadsBubbleEnabled && ( { cancelHide(); setPopoverOpen(true); }} onMouseLeave={scheduleHide} > {popoverOpen && ( )} )}
)} {hasFilters && ( )}
); } interface OverloadInfoPopoverProps { nLinesOverloaded: string[]; nLinesOverloadedRho?: number[]; n1LinesOverloaded: string[]; n1LinesOverloadedRho?: number[]; selectedOverloads: Set | null | undefined; onToggleOverload?: (overload: string) => void; monitorDeselected: boolean; onToggleMonitorDeselected?: () => void; monitoringHint?: string | null; onAssetClick: (actionId: string, assetName: string, tab: 'n' | 'contingency') => void; displayName: (id: string) => string; /** The `?` bubble button the popover anchors to. The popover renders with * `position: fixed` computed from this element's bounding rect so it * escapes the sidebar's `overflow: hidden` clipping and always paints * above the network visualization. */ anchorRef: React.RefObject; } function OverloadInfoPopover({ nLinesOverloaded, nLinesOverloadedRho, n1LinesOverloaded, n1LinesOverloadedRho, selectedOverloads, onToggleOverload, monitorDeselected, onToggleMonitorDeselected, monitoringHint, onAssetClick, displayName, anchorRef, }: OverloadInfoPopoverProps) { const formatRho = (v: number | undefined) => v == null || Number.isNaN(v) ? null : `${(v * 100).toFixed(1)}%`; const hasDeselected = n1LinesOverloaded.some(name => !(selectedOverloads?.has(name) ?? true)); // The sidebar that hosts the `?` bubble sets `overflow: hidden`, so an // absolutely-positioned popover gets clipped (and visually masked by the // network visualization) the moment it spills past the sidebar edge. // Anchor with `position: fixed` against the button's viewport rect instead // so the popover escapes the clip and always paints on top. const POPOVER_MAX_WIDTH = 320; const MARGIN = 8; const [fixedPos, setFixedPos] = useState({ position: 'fixed', visibility: 'hidden', top: 0, left: 0, }); useLayoutEffect(() => { const anchor = anchorRef.current; if (!anchor) return; const rect = anchor.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; // Align the popover's left edge to the bubble, clamped to the viewport. const left = Math.max( MARGIN, Math.min(rect.left, vw - POPOVER_MAX_WIDTH - MARGIN), ); setFixedPos({ position: 'fixed', left, top: Math.min(rect.bottom + 4, vh - MARGIN), maxHeight: vh - rect.bottom - 2 * MARGIN, overflowY: 'auto', }); }, [anchorRef]); return (
e.stopPropagation()} > {monitoringHint && (
{monitoringHint}
)}
N Overloads:{' '} {nLinesOverloaded.length > 0 ? ( {nLinesOverloaded.map((name, i) => { const rhoPct = formatRho(nLinesOverloadedRho?.[i]); return ( {i > 0 && ', '} {rhoPct && ({rhoPct})} ); })} ) : ( None )}
N-1 Overloads:
{n1LinesOverloaded.map((name, i) => { const rhoPct = formatRho(n1LinesOverloadedRho?.[i]); const isSelected = selectedOverloads?.has(name) ?? true; return ( ); })}
{hasDeselected && onToggleMonitorDeselected && ( )}
); }