Co-Study4Grid / frontend /src /components /SidebarSummary.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
20 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 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<string> | 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<number | null>(null);
const bubbleRef = useRef<HTMLButtonElement>(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 (
<div
data-testid="sticky-feed-summary"
style={{
flexShrink: 0,
padding: `6px ${space[3]}`,
background: colors.surfaceMuted,
borderBottom: `1px solid ${colors.border}`,
fontSize: text.xs,
lineHeight: 1.5,
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: space.half,
}}
>
{hasContingency && (
<div
data-testid="sidebar-summary-contingency"
style={{ display: 'flex', alignItems: 'baseline', gap: space[1], flexWrap: 'wrap', width: '100%' }}
>
<span style={{ color: colors.textSecondary, fontWeight: 600, whiteSpace: 'nowrap' }}>
⚡ Contingency{selectedContingency.length > 1 ? ` (N-${selectedContingency.length})` : ''}:
</span>
{selectedContingency.map((id, i) => (
<React.Fragment key={id}>
{i > 0 && <span style={{ color: colors.textSecondary }}>, </span>}
<button
onClick={(e) => { e.stopPropagation(); onContingencyZoom(id); }}
title={`Zoom to ${id} in the current diagram`}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: text.xs,
color: colors.brand,
fontWeight: 600,
textDecoration: 'underline dotted',
wordBreak: 'break-word',
textAlign: 'left',
}}
>
{displayName(id)}
</button>
</React.Fragment>
))}
{onClearContingency && (
<button
type="button"
data-testid="sidebar-summary-clear"
onClick={(e) => { e.stopPropagation(); onClearContingency(); }}
title="Clear the current contingency and pick a new one"
style={{
padding: '3px 10px',
background: colors.danger,
color: colors.textOnBrand,
border: 'none',
borderRadius: radius.sm,
cursor: 'pointer',
fontSize: '11px',
fontWeight: 'bold',
flexShrink: 0,
marginLeft: space[1],
whiteSpace: 'nowrap',
}}
>
Clear
</button>
)}
</div>
)}
{hasOverloads && (
<div
data-testid="sidebar-summary-overloads"
style={{ display: 'flex', alignItems: 'baseline', gap: space[1], position: 'relative' }}
>
<span style={{ color: colors.dangerStrong, fontWeight: 600, whiteSpace: 'nowrap' }}>⚠️ Overloads:</span>
<span style={{ wordBreak: 'break-word' }}>
{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 (
<React.Fragment key={name}>
{i > 0 && ', '}
<button
onClick={(e) => { e.stopPropagation(); onOverloadClick('', name, 'contingency'); }}
onDoubleClick={(e) => {
if (onToggleOverload) {
e.stopPropagation();
onToggleOverload(name);
}
}}
title={onToggleOverload
? (isSelected
? `Zoom to ${name} (Double-click to unselect)`
: `Zoom to ${name} (Double-click to select)`)
: `Open Contingency tab and zoom to ${name}`}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: text.xs,
color: isSelected ? colors.brand : colors.borderStrong,
fontWeight: isSelected ? 600 : 400,
textDecoration: isSelected ? 'underline dotted' : 'none',
}}
>
{displayName(name)}
</button>
{rhoPct && (
<span style={{ color: isSelected ? colors.textPrimary : colors.borderStrong, marginLeft: space.half }}>
({rhoPct})
</span>
)}
</React.Fragment>
);
})}
</span>
{overloadsBubbleEnabled && (
<span
data-testid="sidebar-summary-overloads-bubble-wrap"
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
onMouseEnter={() => { cancelHide(); setPopoverOpen(true); }}
onMouseLeave={scheduleHide}
>
<button
ref={bubbleRef}
data-testid="sidebar-summary-overloads-bubble"
onClick={(e) => { e.stopPropagation(); setPopoverOpen(o => !o); }}
aria-label="Overload details"
title="Show N-state overloads, toggle N-1 overload selection, and monitor-deselected"
style={{
background: colors.chromeSoft,
color: colors.textOnBrand,
border: 'none',
borderRadius: '50%',
width: '14px',
height: '14px',
fontSize: '10px',
lineHeight: 1,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
marginLeft: space[1],
}}
>
?
</button>
{popoverOpen && (
<OverloadInfoPopover
nLinesOverloaded={nLinesOverloaded ?? []}
nLinesOverloadedRho={nLinesOverloadedRho}
n1LinesOverloaded={n1LinesOverloaded ?? []}
n1LinesOverloadedRho={n1LinesOverloadedRho}
selectedOverloads={selectedOverloads}
onToggleOverload={onToggleOverload}
monitorDeselected={monitorDeselected}
onToggleMonitorDeselected={onToggleMonitorDeselected}
monitoringHint={monitoringHint}
onAssetClick={onOverloadClick}
displayName={displayName}
anchorRef={bubbleRef}
/>
)}
</span>
)}
</div>
)}
{hasFilters && (
<ActionFilterRings
filters={overviewFilters!}
onFiltersChange={onOverviewFiltersChange!}
/>
)}
</div>
);
}
interface OverloadInfoPopoverProps {
nLinesOverloaded: string[];
nLinesOverloadedRho?: number[];
n1LinesOverloaded: string[];
n1LinesOverloadedRho?: number[];
selectedOverloads: Set<string> | 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<HTMLButtonElement | null>;
}
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<CSSProperties>({
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 (
<div
data-testid="overload-info-popover"
role="dialog"
style={{
...fixedPos,
zIndex: 300,
background: colors.surface,
border: `1px solid ${colors.border}`,
borderRadius: radius.md,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
padding: `${space[2]} ${space[3]}`,
minWidth: '240px',
maxWidth: '320px',
fontSize: text.xs,
color: colors.textPrimary,
lineHeight: 1.5,
}}
onClick={(e) => e.stopPropagation()}
>
{monitoringHint && (
<div
data-testid="overload-info-monitoring-hint"
style={{
marginBottom: space[1],
fontSize: '11px',
color: colors.textTertiary,
fontStyle: 'italic',
}}
>
{monitoringHint}
</div>
)}
<div style={{ marginBottom: space[1] }}>
<strong style={{ color: colors.textSecondary }}>N Overloads:</strong>{' '}
{nLinesOverloaded.length > 0 ? (
<span style={{ wordBreak: 'break-word' }}>
{nLinesOverloaded.map((name, i) => {
const rhoPct = formatRho(nLinesOverloadedRho?.[i]);
return (
<React.Fragment key={name}>
{i > 0 && ', '}
<button
onClick={(e) => { e.stopPropagation(); onAssetClick('', name, 'n'); }}
title={`Open N tab and zoom to ${name}`}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: 'inherit',
color: colors.brand,
fontWeight: 600,
textDecoration: 'underline dotted',
}}
>
{displayName(name)}
</button>
{rhoPct && <span style={{ marginLeft: space.half }}>({rhoPct})</span>}
</React.Fragment>
);
})}
</span>
) : (
<span style={{ color: colors.textTertiary, fontStyle: 'italic' }}>None</span>
)}
</div>
<div>
<strong style={{ color: colors.textSecondary }}>N-1 Overloads:</strong>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', marginTop: space.half }}>
{n1LinesOverloaded.map((name, i) => {
const rhoPct = formatRho(n1LinesOverloadedRho?.[i]);
const isSelected = selectedOverloads?.has(name) ?? true;
return (
<label
key={name}
data-testid={`overload-info-toggle-${name}`}
title={isSelected
? `Uncheck to exclude ${displayName(name)} from monitoring`
: `Check to include ${displayName(name)} in monitoring`}
style={{
display: 'flex',
alignItems: 'center',
gap: space[1],
cursor: onToggleOverload ? 'pointer' : 'default',
color: isSelected ? colors.textPrimary : colors.borderStrong,
}}
>
<input
type="checkbox"
checked={isSelected}
disabled={!onToggleOverload}
onChange={() => onToggleOverload?.(name)}
onClick={(e) => e.stopPropagation()}
style={{ margin: 0, cursor: onToggleOverload ? 'pointer' : 'default' }}
/>
<button
onClick={(e) => { e.stopPropagation(); onAssetClick('', name, 'contingency'); }}
title={`Open Contingency tab and zoom to ${name}`}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: 'inherit',
color: isSelected ? colors.brand : colors.borderStrong,
fontWeight: isSelected ? 600 : 400,
textDecoration: isSelected ? 'underline dotted' : 'none',
}}
>
{displayName(name)}
</button>
{rhoPct && (
<span style={{ color: isSelected ? colors.textPrimary : colors.borderStrong }}>
({rhoPct})
</span>
)}
</label>
);
})}
</div>
{hasDeselected && onToggleMonitorDeselected && (
<label
data-testid="overload-info-monitor-deselected"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
fontSize: '11px',
color: monitorDeselected ? colors.brandStrong : colors.textSecondary,
fontWeight: monitorDeselected ? 600 : 400,
marginTop: space[1],
}}
title="When checked, deselected overloads are still included in the analysis monitoring scope"
>
<input
type="checkbox"
checked={monitorDeselected}
onChange={onToggleMonitorDeselected}
onClick={(e) => e.stopPropagation()}
style={{ margin: 0, cursor: 'pointer' }}
/>
monitor deselected
</label>
)}
</div>
</div>
);
}