// 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 /* * Pin descriptors posted from the parent React app to the overflow-graph * iframe. The injected overlay (`expert_backend/services/overflow_overlay.py`) * renders one teardrop pin per descriptor anchored on the matching * substation node group (``). * * Pure module — no DOM access, no React — so it can be unit-tested * without jsdom and re-used from a worker if we later move pin * computation off the main thread. */ import type { ActionDetail, ActionOverviewFilters, MetadataIndex, UnsimulatedActionScoreInfo, } from '../../types'; import { actionPassesOverviewFilter, buildUnsimulatedPinTitle, computeActionSeverity, formatPinLabel, type ActionPinInfo, } from './actionPinData'; import { getActionTargetLines, getActionTargetVoltageLevels, } from './highlights'; import { matchesActionTypeFilter } from '../actionTypes'; export interface OverflowPin { actionId: string; /** * Marks combined-action pins (action ids containing ``+``). The * overlay places one such pin at the midpoint of a curved Bézier * connector drawn between the two unitary constituent pins — * mirroring the Action Overview NAD's ``CombinedPinInfo`` layer * (see ``actionPinData.buildCombinedActionPins`` / * ``actionPinRender.renderCombinedPin``). When set, ``action1Id`` * + ``action2Id`` MUST also be populated; ``substation`` / * ``lineNames`` / ``nodeCandidates`` are ignored — the combined * pin's position is derived from the constituent positions * client-side. */ isCombined?: boolean; action1Id?: string; action2Id?: string; /** * When true the unitary pin failed the active overview filter * (severity / threshold / action-type) but is kept on the graph * because a passing combined-action pin references it as one * of its constituents. Mirrors the * ``ActionPinInfo.dimmedByFilter`` flag in * ``actionPinData.ts:39``. The overlay renders dimmedByFilter * pins with a reduced opacity so they read as context for the * combined glyph rather than as first-class actions. */ dimmedByFilter?: boolean; /** * Primary anchor — substation id when the overflow graph nodes are * substations, otherwise the first matching voltage-level id. The * overlay JS tries this name first against ``data-name`` and falls * back to ``nodeCandidates``. */ substation: string; /** * Additional anchor candidates the overlay tries in order if * ``substation`` doesn't match a graph node. Populated with the * action's target voltage-level ids, because some recommender * configurations emit VL-keyed nodes (e.g. small French grid) * while others emit substation-keyed nodes. */ nodeCandidates?: string[]; /** * Line names the overlay should try as edge anchors BEFORE * falling back to a single-node anchor. For branch actions * (disco / reco / max_rho_line) the pin should land at the * midpoint of the connecting edge — same anchoring rule the * Action Overview NAD pins use (see ``resolveActionAnchor`` in * ``actionPinData.ts``). The overlay matches each name against * the ``data-attr-name`` attribute of every ```` * in the SVG and uses the midpoint of the edge's source/target * node centres. */ lineNames?: string[]; /** Loading-percentage label rendered inside the pin body. */ label: string; severity: ActionPinInfo['severity']; isSelected: boolean; isRejected: boolean; /** * Marks scored-but-not-yet-simulated pins. The overlay renders * them with a dashed outline + dimmed fill and re-routes the * double-click message so the parent kicks off a manual * simulation instead of opening the SLD overlay. Mirrors the * Action Overview's unsimulated-pin contract. */ unsimulated?: boolean; /** * Multi-line tooltip text rendered inside the pin's native * ```` element. Populated for un-simulated pins by * ``buildUnsimulatedPinTitle`` so hovering an overflow pin * surfaces the same score / rank / MW-start summary the Action * Overview pin tooltip shows. Falls back to ``actionId`` when * absent, preserving the legacy hover for simulated pins. */ title?: string; } /** * Resolve an action to its first matching SUBSTATION on the overflow * graph. We look for a target VL via the same heuristics used by the * NAD pin layer, then map VL → substation through the network's * `voltage-level-substations` table. * * Returns null when no substation can be resolved (action has no * known VL target, or the network's VLs are not mapped to a * substation — e.g. a pure-VL test fixture). The pin is then silently * dropped, matching the NAD overview behaviour. */ interface ResolvedAnchor { /** Best-guess substation id (overflow graph node ``data-name``). */ substation: string; /** * Voltage-level ids the action targets, ordered so the most * specific match comes first. These act as fallback anchors when * the overflow graph nodes are voltage levels rather than * substations. */ vlIds: string[]; /** * Line names the action targets, in priority order. The overlay * tries each as an edge anchor (midpoint of source/target nodes) * before falling back to single-node candidates. Empty when the * action does not target a line (load shedding / curtailment / * pure topology actions). */ lineNames: string[]; } const resolveActionSubstation = ( actionId: string, details: ActionDetail, metaIndex: MetadataIndex, vlToSubstation: Readonly<Record<string, string>>, overflowSubstationSet: ReadonlySet<string>, ): ResolvedAnchor | null => { const candidateVls: string[] = []; const candidateLines: string[] = []; const pushVl = (vlId: string | null | undefined) => { if (vlId && !candidateVls.includes(vlId)) candidateVls.push(vlId); }; const pushLine = (lineName: string | null | undefined) => { if (lineName && !candidateLines.includes(lineName)) { candidateLines.push(lineName); } }; // Load-shedding / curtailment actions land on a single VL — // they do NOT target a branch even though their description / // id may incidentally contain a line name (e.g. ``max_rho_line`` // is set on EVERY action). EARLY RETURN with only the VL anchor // here, exactly like ``resolveActionAnchor`` in actionPinData, // so the overlay's edge-midpoint path is skipped and the pin // lands on the substation node. if (details.load_shedding_details?.length) { const vlId = details.load_shedding_details[0].voltage_level_id; if (vlId) { const sub = vlToSubstation[vlId]; const substation = (sub && overflowSubstationSet.has(sub)) ? sub : (sub || vlId); return { substation, vlIds: [vlId], lineNames: [] }; } } if (details.curtailment_details?.length) { const vlId = details.curtailment_details[0].voltage_level_id; if (vlId) { const sub = vlToSubstation[vlId]; const substation = (sub && overflowSubstationSet.has(sub)) ? sub : (sub || vlId); return { substation, vlIds: [vlId], lineNames: [] }; } } if (details.redispatch_details?.length) { const vlId = details.redispatch_details[0].voltage_level_id; if (vlId) { const sub = vlToSubstation[vlId]; const substation = (sub && overflowSubstationSet.has(sub)) ? sub : (sub || vlId); return { substation, vlIds: [vlId], lineNames: [] }; } } // Track the action's PRIMARY branch targets vs a max_rho_line // fallback separately so we can apply the same priority order // ``resolveActionAnchor`` uses on the Action Overview NAD: // 1. primary line targets → edge midpoint // 2. voltage-level targets → VL node // 3. max_rho_line → edge midpoint (LAST resort only) // Without this split, coupler / node-merging / generic VL // actions would land on the action's incidental max_rho_line // instead of their actual target VL. const primaryLines: string[] = []; const lineTargets = getActionTargetLines( details, actionId, metaIndex.edgesByEquipmentId, ); for (const lineName of lineTargets) { primaryLines.push(lineName); const edge = metaIndex.edgesByEquipmentId.get(lineName); if (!edge) continue; for (const ref of [edge.node1, edge.node2]) { if (typeof ref !== 'string') continue; const node = metaIndex.nodesBySvgId.get(ref) ?? metaIndex.nodesByEquipmentId.get(ref); if (node?.equipmentId) pushVl(node.equipmentId); } } // Voltage-level targets parsed from the action description / id. const vlTargetIds = getActionTargetVoltageLevels( details, actionId, metaIndex.nodesByEquipmentId, ); for (const vlId of vlTargetIds) { pushVl(vlId); } // max_rho_line — used as a LAST-resort line anchor (after VLs) // AND its endpoints feed candidateVls so pins still resolve when // neither primary lines nor explicit VL targets are present. let fallbackLine: string | null = null; if (details.max_rho_line) { fallbackLine = details.max_rho_line; const edge = metaIndex.edgesByEquipmentId.get(details.max_rho_line); if (edge) { for (const ref of [edge.node1, edge.node2]) { if (typeof ref !== 'string') continue; const node = metaIndex.nodesBySvgId.get(ref) ?? metaIndex.nodesByEquipmentId.get(ref); pushVl(node?.equipmentId); } } } // Pick the line-anchor strategy in priority order. When the // action has VL targets (couplers, node-merging, …) we leave // ``candidateLines`` empty so the overlay goes straight to the // VL node anchor — the max_rho_line is only an anchor of last // resort. if (primaryLines.length > 0) { for (const ln of primaryLines) pushLine(ln); } else if (vlTargetIds.length === 0 && fallbackLine) { pushLine(fallbackLine); } if (candidateVls.length === 0 && candidateLines.length === 0) { return null; } // Pick the first VL whose substation is present in the overflow // graph (best UX when the graph is substation-keyed). Otherwise // fall back to the first candidate's substation — the overlay JS // will still try the VL ids themselves as anchor candidates. let substation: string | null = null; for (const vlId of candidateVls) { const sub = vlToSubstation[vlId]; if (sub && overflowSubstationSet.has(sub)) { substation = sub; break; } } if (!substation) { // No substation matched the overflow set; pick the first // mapped substation (or the first VL itself when no mapping // exists — e.g. test fixtures). for (const vlId of candidateVls) { const sub = vlToSubstation[vlId]; if (sub) { substation = sub; break; } } } if (!substation) substation = candidateVls[0] ?? ""; return { substation, vlIds: candidateVls, lineNames: candidateLines }; }; /** * Build the descriptor list to post to the overflow-graph iframe. * * @param overflowSubstations Optional set of substation ids known to * be present in the overflow graph. When * provided, pins for substations outside * the set are dropped (avoids pinning on * a substation hidden by the * `keep_overloads_components` filter). * When omitted, every resolvable pin is * kept — useful for tests. */ export const buildOverflowPinPayload = ( actions: Record<string, ActionDetail> | null | undefined, metaIndex: MetadataIndex | null | undefined, vlToSubstation: Readonly<Record<string, string>>, monitoringFactor: number, selectedActionIds: ReadonlySet<string>, rejectedActionIds: ReadonlySet<string>, overflowSubstations?: ReadonlySet<string>, /** * Optional Action-Overview-style filters. When passed, pins are * dropped according to the SAME rules ``ActionOverviewDiagram`` * applies to its NAD pins (severity category, max-loading * threshold, action-type chip). Without it every resolvable pin * is emitted (legacy behaviour preserved for tests / standalone). */ overviewFilters?: ActionOverviewFilters, ): OverflowPin[] => { if (!actions || !metaIndex) return []; // When no substation set is provided, accept every resolved one. const knownSet = overflowSubstations ?? new Set<string>(); const acceptAny = !overflowSubstations; // Three-pass pipeline mirroring ``ActionOverviewDiagram`` (the // ``pins``/``combinedPins`` memos at component.tsx:314-380): // // 1. Build every unitary anchor UNFILTERED so combined pins // have endpoints to anchor on even when one half would // fail the active severity / threshold / action-type chip. // 2. Determine which combined actions pass the filter; their // two unitary ids form the ``protectedIds`` set. // 3. Re-filter the unitary list: // - passes filter → emit at full strength, // - fails but in protectedIds → emit with dimmedByFilter, // - fails and unprotected → drop entirely. // 4. Emit combined-pin descriptors for the passing combined // actions. The overlay JS computes their midpoint client- // side from the constituent positions. interface UnitaryDraft { actionId: string; details: ActionDetail; anchor: ResolvedAnchor; } const unitaryDrafts: UnitaryDraft[] = []; const unitaryDraftIds = new Set<string>(); for (const [actionId, details] of Object.entries(actions)) { if (actionId.includes('+')) continue; const anchor = resolveActionSubstation( actionId, details, metaIndex, vlToSubstation, acceptAny ? new Set(Object.values(vlToSubstation)) : knownSet, ); if (!anchor) continue; unitaryDrafts.push({ actionId, details, anchor }); unitaryDraftIds.add(actionId); } // ``passesAll`` mirrors the same name in ActionOverviewDiagram — // category + threshold AND the single-select action-type chip. const passesAll = (id: string, det: ActionDetail): boolean => { if (!overviewFilters) return true; if (!actionPassesOverviewFilter( det, monitoringFactor, overviewFilters.categories, overviewFilters.threshold, )) return false; return matchesActionTypeFilter( overviewFilters.actionType, id, det.description_unitaire, null, ); }; // A combined pin passes the type chip if EITHER constituent // matches — combined actions are inherently multi-type and // hiding a pair because one side doesn't match would surprise // the operator. (Identical rule to ``combinedPassesTypeFilter`` // in ActionOverviewDiagram.) const combinedPassesTypeFilter = (id1: string, id2: string): boolean => { if (!overviewFilters || overviewFilters.actionType === 'all') return true; const d1 = actions[id1]; const d2 = actions[id2]; return ( (d1 ? matchesActionTypeFilter( overviewFilters.actionType, id1, d1.description_unitaire, null, ) : false) || (d2 ? matchesActionTypeFilter( overviewFilters.actionType, id2, d2.description_unitaire, null, ) : false) ); }; // Pass 2 — find combined actions that pass the filter and // build the protected-id set. interface CombinedDraft { actionId: string; details: ActionDetail; action1Id: string; action2Id: string; } const combinedDrafts: CombinedDraft[] = []; const protectedIds = new Set<string>(); for (const [actionId, details] of Object.entries(actions)) { if (!actionId.includes('+')) continue; const parts = actionId.split('+'); if (parts.length !== 2) continue; const [id1, id2] = parts; if (!unitaryDraftIds.has(id1) || !unitaryDraftIds.has(id2)) continue; if (overviewFilters) { if (!actionPassesOverviewFilter( details, monitoringFactor, overviewFilters.categories, overviewFilters.threshold, )) continue; if (!combinedPassesTypeFilter(id1, id2)) continue; } combinedDrafts.push({ actionId, details, action1Id: id1, action2Id: id2 }); protectedIds.add(id1); protectedIds.add(id2); } // Pass 3 — emit the unitary pins, dimming the protected fails. // When the "combined only" toggle is active, drop every unitary // pin that is NOT a constituent of a passing combined pin and // mark the survivors as dimmed-by-filter — same contract as the // ``ActionOverviewDiagram`` ``pins`` memo, so the two surfaces // stay in lock-step. const combinedOnly = !!overviewFilters?.showCombinedOnly; const out: OverflowPin[] = []; for (const u of unitaryDrafts) { const passes = passesAll(u.actionId, u.details); if (combinedOnly) { if (!protectedIds.has(u.actionId)) continue; } else if (!passes && !protectedIds.has(u.actionId)) { continue; } const dimmedByFilter = combinedOnly || !passes; out.push({ actionId: u.actionId, substation: u.anchor.substation, nodeCandidates: u.anchor.vlIds.filter(v => v !== u.anchor.substation), lineNames: u.anchor.lineNames, label: formatPinLabel(u.details), severity: computeActionSeverity(u.details, monitoringFactor), isSelected: selectedActionIds.has(u.actionId), isRejected: rejectedActionIds.has(u.actionId), ...(dimmedByFilter ? { dimmedByFilter: true } : {}), }); } // Pass 4 — emit the combined-pin descriptors. for (const cp of combinedDrafts) { out.push({ actionId: cp.actionId, isCombined: true, action1Id: cp.action1Id, action2Id: cp.action2Id, substation: '', label: formatPinLabel(cp.details), severity: computeActionSeverity(cp.details, monitoringFactor), isSelected: selectedActionIds.has(cp.actionId), isRejected: rejectedActionIds.has(cp.actionId), }); } return out; }; /** * Build the descriptor list of un-simulated action pins for the * overflow-graph iframe. Mirrors ``buildUnsimulatedActionPins`` in * ``actionPinData.ts``: every scored-but-not-simulated id resolves * via the same anchor logic with a stub ``ActionDetail``, lands as * a dimmed pin with ``severity='grey'`` / ``label='?'`` and the * ``unsimulated: true`` flag the overlay reads to switch shape / * dblclick semantics. * * Items already in ``simulatedIds`` are skipped (they're rendered * by ``buildOverflowPinPayload`` instead). * * When ``overviewFilters.actionType`` is provided and not ``'all'``, * ids that don't match the active type chip (DISCO / RECO / LS / * OPEN / CLOSE / PST / RC) are dropped — mirroring the Action * Overview's ``unsimulatedPins`` memo so the chip filter behaves * consistently across both tabs. Like the Overview, we prefer the * score-info ``type`` field when available and fall back to * id-based heuristics in ``classifyActionType``. */ export const buildOverflowUnsimulatedPinPayload = ( scoredActionIds: readonly string[], simulatedIds: ReadonlySet<string>, metaIndex: MetadataIndex | null | undefined, vlToSubstation: Readonly<Record<string, string>>, scoreInfo?: Readonly<Record<string, UnsimulatedActionScoreInfo>>, overflowSubstations?: ReadonlySet<string>, overviewFilters?: ActionOverviewFilters, ): OverflowPin[] => { if (!metaIndex || scoredActionIds.length === 0) return []; // Un-simulated actions can never participate in a computed pair — // when the operator restricts the overview to combined-only pins // there is nothing for this layer to emit. if (overviewFilters?.showCombinedOnly) return []; const knownSet = overflowSubstations ?? new Set<string>(); const acceptAny = !overflowSubstations; // Stub ActionDetail used for anchor resolution — un-simulated // actions have no rho yet, so we feed empty strings / nulls and // rely on the action id's structure (line / VL / coupler tokens) // to drive ``getActionTargetLines`` / ``getActionTargetVoltageLevels``. const stub: ActionDetail = { description_unitaire: '', rho_before: null, rho_after: null, max_rho: null, max_rho_line: '', is_rho_reduction: false, }; const activeType = overviewFilters?.actionType ?? 'all'; const out: OverflowPin[] = []; const seen = new Set<string>(); for (const rawId of scoredActionIds) { const id = rawId.trim(); if (!id || seen.has(id)) continue; seen.add(id); if (simulatedIds.has(id)) continue; // Action-type chip filter — same rule as the Action Overview // (`ActionOverviewDiagram.tsx` `unsimulatedPins` memo). if (activeType !== 'all') { const scoreType = scoreInfo?.[id]?.type ?? null; if (!matchesActionTypeFilter(activeType, id, null, scoreType)) continue; } const anchor = resolveActionSubstation( id, stub, metaIndex, vlToSubstation, acceptAny ? new Set(Object.values(vlToSubstation)) : knownSet, ); if (!anchor) continue; out.push({ actionId: id, substation: anchor.substation, nodeCandidates: anchor.vlIds.filter(v => v !== anchor.substation), lineNames: anchor.lineNames, label: '?', severity: 'grey', isSelected: false, isRejected: false, unsimulated: true, // Multi-line hover tooltip mirrors the Action Overview's // un-simulated pin: id + score + rank + MW-start. title: buildUnsimulatedPinTitle(id, scoreInfo?.[id]), }); } return out; };