Spaces:
Sleeping
Sleeping
| // 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, { useMemo } from 'react'; | |
| import Select, { type MultiValue } from 'react-select'; | |
| import type { ActionOverviewFilters } from '../types'; | |
| import SidebarSummary from './SidebarSummary'; | |
| import { colors, radius, space } from '../styles/tokens'; | |
| interface ContingencyOption { | |
| value: string; | |
| label: string; | |
| } | |
| interface AppSidebarProps { | |
| /** Currently APPLIED contingency (list of element IDs disconnected). */ | |
| selectedContingency: string[]; | |
| /** Pending list the user is composing — committed via Apply button. */ | |
| pendingContingency: string[]; | |
| branches: string[]; | |
| nameMap: Record<string, string>; | |
| n1LinesOverloaded: string[] | undefined; | |
| n1LinesOverloadedRho: number[] | undefined; | |
| /** N-state pre-existing overloads, surfaced in the SidebarSummary info bubble. */ | |
| nLinesOverloaded?: string[]; | |
| nLinesOverloadedRho?: number[]; | |
| selectedOverloads: Set<string> | null | undefined; | |
| /** Replace the pending list with the user's current selection. */ | |
| onPendingContingencyChange: (next: string[]) => void; | |
| /** Commit ``pendingContingency`` as the applied contingency. */ | |
| onContingencyApply: () => void; | |
| 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. | |
| * Forwarded to SidebarSummary's info bubble. */ | |
| onToggleOverload?: (overload: string) => void; | |
| monitorDeselected?: boolean; | |
| onToggleMonitorDeselected?: () => void; | |
| monitoringHint?: string | null; | |
| /** Drop the current contingency to investigate a new one. Triggers | |
| * the confirmation dialog at the call site. */ | |
| onClearContingency?: () => void; | |
| /** Hide the "Select Contingency" card. Used after a contingency has | |
| * been committed AND its overloads detected, since the sticky banner | |
| * then carries the same info plus a Clear shortcut. */ | |
| hideContingencyPicker?: boolean; | |
| /** Collapsed mode: the sidebar shrinks to a thin strip so the | |
| * visualization panel can take the full width. */ | |
| collapsed?: boolean; | |
| onToggleCollapsed?: () => void; | |
| /** Shared severity + action-type filters; forwarded to | |
| * SidebarSummary so the persistent strip can host the filter | |
| * rings alongside the contingency / overload lines. */ | |
| overviewFilters?: ActionOverviewFilters; | |
| onOverviewFiltersChange?: (next: ActionOverviewFilters) => void; | |
| /** Whether the action feed has any card to filter right now. */ | |
| hasActions?: boolean; | |
| children: React.ReactNode; | |
| } | |
| /** | |
| * Left-sidebar layout shell: | |
| * | |
| * - A COMPACT sticky strip at the top (<SidebarSummary>) keeps only | |
| * the clickable fields of interest visible while scrolling | |
| * (selected contingency → zoom active tab; contingency overloads → | |
| * jump to the contingency tab + zoom). The strip also hosts the | |
| * Clear-contingency shortcut and the overload-info bubble. | |
| * - The Select Contingency card with the multi-select picker + Trigger | |
| * button shows below the strip — but only as long as no contingency | |
| * has been committed (after that, the strip carries the same info | |
| * and the picker would be redundant). | |
| * - The ActionFeed (rendered as ``children``) sits below. | |
| * | |
| * The shell can be collapsed to a thin strip when the operator wants | |
| * to expand the visualization panel. | |
| */ | |
| export default function AppSidebar({ | |
| selectedContingency, | |
| pendingContingency, | |
| branches, | |
| nameMap, | |
| n1LinesOverloaded, | |
| n1LinesOverloadedRho, | |
| nLinesOverloaded, | |
| nLinesOverloadedRho, | |
| selectedOverloads, | |
| onPendingContingencyChange, | |
| onContingencyApply, | |
| displayName, | |
| onContingencyZoom, | |
| onOverloadClick, | |
| onToggleOverload, | |
| monitorDeselected, | |
| onToggleMonitorDeselected, | |
| monitoringHint, | |
| onClearContingency, | |
| hideContingencyPicker, | |
| collapsed, | |
| onToggleCollapsed, | |
| overviewFilters, | |
| onOverviewFiltersChange, | |
| hasActions, | |
| children, | |
| }: AppSidebarProps) { | |
| // Pending differs from applied → user has unconfirmed edits to the | |
| // contingency that won't take effect until they hit Trigger. | |
| const samePendingApplied = | |
| pendingContingency.length === selectedContingency.length && | |
| pendingContingency.every((e, i) => e === selectedContingency[i]); | |
| const dirty = !samePendingApplied; | |
| const branchOptions: ContingencyOption[] = useMemo( | |
| () => branches.map(b => ({ | |
| value: b, | |
| label: nameMap[b] ? `${nameMap[b]} — ${b}` : b, | |
| })), | |
| [branches, nameMap], | |
| ); | |
| const optionByValue = useMemo(() => { | |
| const m = new Map<string, ContingencyOption>(); | |
| for (const o of branchOptions) m.set(o.value, o); | |
| return m; | |
| }, [branchOptions]); | |
| const selectedOptions: ContingencyOption[] = useMemo( | |
| () => pendingContingency.map(id => | |
| optionByValue.get(id) ?? { value: id, label: nameMap[id] ? `${nameMap[id]} — ${id}` : id } | |
| ), | |
| [pendingContingency, optionByValue, nameMap], | |
| ); | |
| if (collapsed) { | |
| return ( | |
| <div | |
| data-testid="sidebar" | |
| data-collapsed="true" | |
| style={{ | |
| width: '32px', | |
| background: colors.borderSubtle, | |
| borderRight: `1px solid ${colors.border}`, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| padding: `${space[2]} 0`, | |
| }} | |
| > | |
| <button | |
| data-testid="sidebar-expand-button" | |
| onClick={onToggleCollapsed} | |
| title="Expand sidebar" | |
| style={{ | |
| background: colors.surface, | |
| border: `1px solid ${colors.border}`, | |
| borderRadius: radius.sm, | |
| cursor: 'pointer', | |
| width: '24px', | |
| height: '24px', | |
| fontSize: '12px', | |
| color: colors.textSecondary, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| padding: 0, | |
| }} | |
| > | |
| › | |
| </button> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div data-testid="sidebar" style={{ width: '25%', background: colors.borderSubtle, borderRight: `1px solid ${colors.border}`, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}> | |
| {onToggleCollapsed && ( | |
| <button | |
| data-testid="sidebar-collapse-button" | |
| onClick={onToggleCollapsed} | |
| title="Collapse sidebar to widen the visualization panel" | |
| style={{ | |
| position: 'absolute', | |
| top: '4px', | |
| right: '4px', | |
| zIndex: 5, | |
| background: colors.surface, | |
| border: `1px solid ${colors.border}`, | |
| borderRadius: radius.sm, | |
| cursor: 'pointer', | |
| width: '20px', | |
| height: '20px', | |
| fontSize: '11px', | |
| color: colors.textSecondary, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| padding: 0, | |
| lineHeight: 1, | |
| }} | |
| > | |
| ‹ | |
| </button> | |
| )} | |
| <SidebarSummary | |
| selectedContingency={selectedContingency} | |
| n1LinesOverloaded={n1LinesOverloaded} | |
| n1LinesOverloadedRho={n1LinesOverloadedRho} | |
| nLinesOverloaded={nLinesOverloaded} | |
| nLinesOverloadedRho={nLinesOverloadedRho} | |
| selectedOverloads={selectedOverloads} | |
| displayName={displayName} | |
| onContingencyZoom={onContingencyZoom} | |
| onOverloadClick={onOverloadClick} | |
| onToggleOverload={onToggleOverload} | |
| monitorDeselected={monitorDeselected} | |
| onToggleMonitorDeselected={onToggleMonitorDeselected} | |
| monitoringHint={monitoringHint} | |
| onClearContingency={onClearContingency} | |
| overviewFilters={overviewFilters} | |
| onOverviewFiltersChange={onOverviewFiltersChange} | |
| hasActions={hasActions} | |
| /> | |
| <div style={{ flex: 1, overflowY: 'auto', padding: space[4], minHeight: 0, display: 'flex', flexDirection: 'column', gap: space[4] }}> | |
| {branches.length > 0 && !hideContingencyPicker && ( | |
| <div style={{ flexShrink: 0, padding: `${space[3]} ${space[4]}`, background: colors.surface, borderRadius: radius.lg, border: `1px solid ${colors.border}`, boxShadow: '0 2px 4px rgba(0,0,0,0.05)' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: space[2], marginBottom: '5px' }}> | |
| <label style={{ fontSize: '0.8rem', fontWeight: 'bold' }}>⚡ Select Contingency</label> | |
| <button | |
| type="button" | |
| data-testid="contingency-trigger" | |
| onClick={onContingencyApply} | |
| disabled={pendingContingency.length === 0 || !dirty} | |
| title={ | |
| pendingContingency.length === 0 | |
| ? 'Pick at least one element first' | |
| : dirty | |
| ? `Trigger contingency (${pendingContingency.length} element${pendingContingency.length > 1 ? 's' : ''})` | |
| : 'Already triggered' | |
| } | |
| style={{ | |
| padding: `${space[1]} ${space[3]}`, | |
| background: pendingContingency.length === 0 || !dirty ? colors.disabled : colors.accent, | |
| color: colors.textOnBrand, | |
| border: 'none', | |
| borderRadius: radius.sm, | |
| cursor: pendingContingency.length === 0 || !dirty ? 'not-allowed' : 'pointer', | |
| fontWeight: 'bold', | |
| fontSize: '0.78rem', | |
| whiteSpace: 'nowrap', | |
| }} | |
| > | |
| ▶ Trigger | |
| </button> | |
| </div> | |
| <Select<ContingencyOption, true> | |
| isMulti | |
| isClearable={false} | |
| options={branchOptions} | |
| value={selectedOptions} | |
| onChange={(next: MultiValue<ContingencyOption>) => | |
| onPendingContingencyChange(next.map(o => o.value)) | |
| } | |
| placeholder="Search line/bus…" | |
| noOptionsMessage={() => 'No matching elements'} | |
| classNamePrefix="cs4g-contingency" | |
| styles={{ | |
| control: (base) => ({ | |
| ...base, | |
| minHeight: 36, | |
| borderRadius: radius.sm, | |
| borderColor: colors.border, | |
| background: colors.surface, | |
| fontSize: '0.85rem', | |
| }), | |
| input: (base) => ({ ...base, color: colors.textPrimary }), | |
| placeholder: (base) => ({ ...base, color: colors.textTertiary }), | |
| multiValue: (base) => ({ | |
| ...base, | |
| background: colors.borderSubtle, | |
| border: `1px solid ${colors.border}`, | |
| }), | |
| multiValueLabel: (base) => ({ | |
| ...base, | |
| color: colors.textPrimary, | |
| fontSize: '0.78rem', | |
| }), | |
| option: (base, state) => ({ | |
| ...base, | |
| fontSize: '0.85rem', | |
| background: state.isFocused ? colors.borderSubtle : 'transparent', | |
| color: colors.textPrimary, | |
| cursor: 'pointer', | |
| }), | |
| menu: (base) => ({ ...base, zIndex: 30, background: colors.surface }), | |
| }} | |
| /> | |
| </div> | |
| )} | |
| {children} | |
| </div> | |
| </div> | |
| ); | |
| } | |