// 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 /* * Action-overview pin DATA builders — pure functions, no DOM access. * * Separated from `actionPinRender.ts` so the severity palette, anchor * resolution, and pin descriptor construction can be tested without * jsdom. */ import type { ActionDetail, ActionSeverityCategory, MetadataIndex, NodeMeta, UnsimulatedActionScoreInfo, } from '../../types'; import { pinColors, pinColorsDimmed, pinColorsHighlighted } from '../../styles/tokens'; import { getActionTargetLines, getActionTargetVoltageLevels } from './highlights'; export interface ActionPinInfo { id: string; x: number; y: number; severity: 'green' | 'orange' | 'red' | 'grey'; label: string; title: string; /** * When true this pin represents an action from the scored table * that has NOT yet been simulated. Rendered with a dashed outline * and reduced opacity, and double-clicking it kicks off a manual * simulation (the same code path as the Manual Selection dropdown). */ unsimulated?: boolean; /** * When true this pin was filtered out by the overview header * filter but kept visible because a passing combined action * references it. Rendered with reduced opacity so it reads as * a context pin rather than a first-class action. */ dimmedByFilter?: boolean; } /** * Descriptor for a combined-action pin — rendered at the midpoint of * a curved connection between the two unitary action pins it combines. */ export interface CombinedPinInfo { /** Pair key, e.g. "action1+action2". */ pairId: string; /** The two unitary action ids. */ action1Id: string; action2Id: string; /** Anchor positions of the two unitary pins (endpoints of the curve). */ p1: { x: number; y: number }; p2: { x: number; y: number }; /** Midpoint of the curve (where the combined pin sits). */ x: number; y: number; /** Max loading after combined application. */ label: string; title: string; severity: ActionPinInfo['severity']; } export const severityFill: Record = pinColors; /** * Dimmed fill colours for rejected actions — each severity hue is * shifted toward grey and lowered in saturation so the pin recedes * visually while still being colour-identifiable. */ export const severityFillDimmed: Record = pinColorsDimmed; /** * Highlighted (selected) fill colours — slightly more vivid/brighter * versions of the severity palette so the pin stands out. */ export const severityFillHighlighted: Record = pinColorsHighlighted; export const computeActionSeverity = ( details: ActionDetail, monitoringFactor: number, ): ActionPinInfo['severity'] => { if (details.non_convergence || details.is_islanded) return 'grey'; if (details.max_rho == null) { return details.is_rho_reduction ? 'green' : 'red'; } if (details.max_rho > monitoringFactor) return 'red'; if (details.max_rho > monitoringFactor - 0.05) return 'orange'; return 'green'; }; /** * Predicate used by both the overview (pin visibility) and the sidebar * ActionFeed (card visibility) to decide whether an action passes the * active category + threshold filters. * * - `categoryEnabled` controls the four severity buckets (green / orange * / red / grey); if the action's bucket is disabled, it is hidden. * - `threshold` is a max-loading cap: actions whose `max_rho` is * **strictly greater** than the threshold are hidden. Actions with a * null `max_rho` (divergent / islanded — all in the 'grey' bucket) * bypass the threshold so the operator keeps seeing non-numeric * outcomes when the grey category is enabled. */ export const actionPassesOverviewFilter = ( details: ActionDetail, monitoringFactor: number, categoryEnabled: Record, threshold: number, ): boolean => { const severity = computeActionSeverity(details, monitoringFactor); if (!categoryEnabled[severity]) return false; if (details.max_rho != null && details.max_rho > threshold) return false; return true; }; /** * Format the loading label shown on the pin body. * * Exported so tests can pin the exact string contract: percentage, * "DIV" on divergence, "ISL" on islanding, or em-dash otherwise. */ export const formatPinLabel = (details: ActionDetail): string => { if (details.max_rho != null) return `${(details.max_rho * 100).toFixed(0)}%`; if (details.non_convergence) return 'DIV'; if (details.is_islanded) return 'ISL'; return '\u2014'; }; /** * Build the human-readable title (tooltip) shown on hover. The `idLabel` * is the pin id as the user thinks of it (e.g. "a+b" for combined). */ export const formatPinTitle = (idLabel: string, details: ActionDetail): string => { const parts = [ idLabel, details.description_unitaire, details.max_rho != null ? `max loading ${(details.max_rho * 100).toFixed(1)}%${details.max_rho_line ? ` on ${details.max_rho_line}` : ''}` : details.non_convergence ? 'load-flow divergent' : details.is_islanded ? 'islanding' : '', ]; return parts.filter(Boolean).join(' \u2014 '); }; /** * Resolve an action to a point on the NAD background. * * For line / PST actions we take the midpoint of the edge; for * nodal actions we take the voltage-level node position. Returns * null when no impacted asset can be located — the pin is then * silently skipped. */ export const resolveActionAnchor = ( actionId: string, details: ActionDetail, metaIndex: MetadataIndex, ): { x: number; y: number } | null => { const { nodesByEquipmentId, nodesBySvgId, edgesByEquipmentId } = metaIndex; const lookupNode = (nodeRef: unknown): NodeMeta | undefined => { if (typeof nodeRef !== 'string') return undefined; return nodesBySvgId.get(nodeRef) ?? nodesByEquipmentId.get(nodeRef); }; // Load shedding / curtailment actions carry an explicit // voltage_level_id in their detail objects — use it directly // so the pin lands on the VL node, not on an unrelated line. if (details.load_shedding_details?.length) { const vlId = details.load_shedding_details[0].voltage_level_id; if (vlId) { const node = nodesByEquipmentId.get(vlId); if (node && Number.isFinite(node.x)) return { x: node.x, y: node.y }; } } if (details.curtailment_details?.length) { const vlId = details.curtailment_details[0].voltage_level_id; if (vlId) { const node = nodesByEquipmentId.get(vlId); if (node && Number.isFinite(node.x)) return { x: node.x, y: node.y }; } } if (details.redispatch_details?.length) { const vlId = details.redispatch_details[0].voltage_level_id; if (vlId) { const node = nodesByEquipmentId.get(vlId); if (node && Number.isFinite(node.x)) return { x: node.x, y: node.y }; } } // Try line targets first const lineTargets = getActionTargetLines(details, actionId, edgesByEquipmentId); for (const lineName of lineTargets) { const edge = edgesByEquipmentId.get(lineName); if (!edge) continue; const n1 = lookupNode(edge.node1); const n2 = lookupNode(edge.node2); if (n1 && n2 && Number.isFinite(n1.x) && Number.isFinite(n2.x)) { return { x: (n1.x + n2.x) / 2, y: (n1.y + n2.y) / 2 }; } if (n1 && Number.isFinite(n1.x)) return { x: n1.x, y: n1.y }; if (n2 && Number.isFinite(n2.x)) return { x: n2.x, y: n2.y }; } // Fallback on voltage-level targets const vlTargets = getActionTargetVoltageLevels(details, actionId, nodesByEquipmentId); for (const vlName of vlTargets) { const node = nodesByEquipmentId.get(vlName); if (node && Number.isFinite(node.x)) { return { x: node.x, y: node.y }; } } // Last resort: max_rho_line (a line the action redistributes onto) if (details.max_rho_line) { const edge = edgesByEquipmentId.get(details.max_rho_line); if (edge) { const n1 = lookupNode(edge.node1); const n2 = lookupNode(edge.node2); if (n1 && n2 && Number.isFinite(n1.x) && Number.isFinite(n2.x)) { return { x: (n1.x + n2.x) / 2, y: (n1.y + n2.y) / 2 }; } } } return null; }; /** * Fan out pins that share the same anchor position so they don't * stack on top of each other and remain individually clickable. * * Mutates `pins` in place. Exported so tests can exercise the * fan-out independently from the full builder. */ export const fanOutColocatedPins = (pins: ActionPinInfo[], offsetRadius = 30 * 1.2): void => { const bucketKey = (p: ActionPinInfo) => `${Math.round(p.x * 100)}:${Math.round(p.y * 100)}`; const groups = new Map(); pins.forEach((p, i) => { const k = bucketKey(p); const arr = groups.get(k); if (arr) arr.push(i); else groups.set(k, [i]); }); for (const indices of groups.values()) { if (indices.length < 2) continue; const angleStep = (2 * Math.PI) / indices.length; indices.forEach((idx, i) => { const angle = -Math.PI / 2 + i * angleStep; pins[idx] = { ...pins[idx], x: pins[idx].x + offsetRadius * Math.cos(angle), y: pins[idx].y + offsetRadius * Math.sin(angle), }; }); } }; /** * Build the list of pin descriptors for the action-overview view. * Pure function — no DOM access — so it can be unit-tested. * * When `overviewFilter` is provided, pins whose action does not pass * the active category + threshold filters are dropped. The same * predicate drives the ActionFeed card filtering, keeping the two * views in lock-step. */ export const buildActionOverviewPins = ( actions: Record, metaIndex: MetadataIndex, monitoringFactor: number, filterIds?: Iterable, overviewFilter?: { categories: Record; threshold: number; } | null, ): ActionPinInfo[] => { const allowed = filterIds ? new Set(filterIds) : null; const pins: ActionPinInfo[] = []; for (const [actionId, details] of Object.entries(actions)) { if (allowed && !allowed.has(actionId)) continue; // Skip combined-action entries (key contains '+') — those are // rendered separately by buildCombinedActionPins with a curved // connection between their constituent unitary pins. if (actionId.includes('+')) continue; if (overviewFilter && !actionPassesOverviewFilter( details, monitoringFactor, overviewFilter.categories, overviewFilter.threshold, )) continue; const anchor = resolveActionAnchor(actionId, details, metaIndex); if (!anchor) continue; const severity = computeActionSeverity(details, monitoringFactor); const label = formatPinLabel(details); const title = formatPinTitle(actionId, details); pins.push({ id: actionId, x: anchor.x, y: anchor.y, severity, label, title }); } fanOutColocatedPins(pins); return pins; }; /** * Format the SVG-title tooltip shown on hover for an un-simulated pin. * When {@link scoreInfo} is provided the tooltip carries the same * score-table data the Manual Selection dropdown exposes (type, * score, rank in category, MW/tap start), so the operator can triage * without leaving the overview. Falls back to a generic prompt when * score info is absent (e.g. older session reloads). */ export const buildUnsimulatedPinTitle = ( id: string, info: UnsimulatedActionScoreInfo | undefined, ): string => { if (!info) { return `${id} — not yet simulated (double-click to run)`; } const lines: string[] = [ `${id} — not yet simulated (double-click to run)`, `Type: ${info.type}`, `Score: ${info.score.toFixed(2)} — rank ${info.rankInType} of ${info.countInType} (max ${info.maxScoreInType.toFixed(2)})`, ]; if (info.mwStart != null) { lines.push(`MW start: ${info.mwStart.toFixed(1)} MW`); } if (info.tapStart) { const ts = info.tapStart; const range = ts.low_tap != null && ts.high_tap != null ? ` (range ${ts.low_tap} … ${ts.high_tap})` : ''; lines.push(`Tap start: ${ts.tap}${range}`); } return lines.join('\n'); }; /** * Build pin descriptors for actions that appear in the score table but * have NOT yet been simulated. These are rendered as dimmed, dashed * pins the operator can double-click to trigger a manual simulation. * * An unsimulated action has no `ActionDetail`, so anchoring goes * through `resolveActionAnchor` with a minimal stub; the helper * internally falls back on edge/VL lookups based on the id alone, * which matches what the score table does for line / coupling / PST * actions. Items that cannot be resolved are silently skipped. * * Pins whose id is already present in `simulatedIds` are skipped so * we never double-pin an action that is both scored and simulated. * * Pure function — no DOM access. */ export const buildUnsimulatedActionPins = ( scoredActionIds: readonly string[], simulatedIds: ReadonlySet, metaIndex: MetadataIndex, scoreInfo?: Readonly>, ): ActionPinInfo[] => { const pins: ActionPinInfo[] = []; const seen = new Set(); const stub: ActionDetail = { description_unitaire: '', rho_before: null, rho_after: null, max_rho: null, max_rho_line: '', is_rho_reduction: false, }; for (const rawId of scoredActionIds) { const id = rawId.trim(); if (!id || seen.has(id)) continue; seen.add(id); if (simulatedIds.has(id)) continue; const anchor = resolveActionAnchor(id, stub, metaIndex); if (!anchor) continue; pins.push({ id, x: anchor.x, y: anchor.y, severity: 'grey', label: '?', title: buildUnsimulatedPinTitle(id, scoreInfo?.[id]), unsimulated: true, }); } fanOutColocatedPins(pins); return pins; }; /** * Quadratic Bezier midpoint of (p1, ctrl, p2) at t=0.5. * Exported so the renderer can reuse the same control-point math for * drawing the connecting curve path. */ export const curveMidpoint = ( p1: { x: number; y: number }, p2: { x: number; y: number }, offsetFraction = 0.3, ): { ctrlX: number; ctrlY: number; midX: number; midY: number } => { const dx = p2.x - p1.x; const dy = p2.y - p1.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const ctrlX = (p1.x + p2.x) / 2 + (-dy / dist) * dist * offsetFraction; const ctrlY = (p1.y + p2.y) / 2 + (dx / dist) * dist * offsetFraction; const t = 0.5; const midX = (1 - t) * (1 - t) * p1.x + 2 * t * (1 - t) * ctrlX + t * t * p2.x; const midY = (1 - t) * (1 - t) * p1.y + 2 * t * (1 - t) * ctrlY + t * t * p2.y; return { ctrlX, ctrlY, midX, midY }; }; /** * Build descriptors for combined-action pins. A simulated combined * action is identified by an action key containing '+' in the * `actions` dict (e.g. "disco_X+reco_Y"). For each such entry the * function locates the two constituent unitary pins and produces a * `CombinedPinInfo` with a curved connection between them and a * dedicated pin at the curve midpoint. * * NOTE: simulated pairs land in `actions` (not `combined_actions`) * — see CombinedActionsModal's handleSimulate. That is why this * function scans `actions` for '+' keys rather than iterating over * `combined_actions`. * * Pure function — no DOM access. */ export const buildCombinedActionPins = ( actions: Record | null | undefined, unitaryPins: readonly ActionPinInfo[], monitoringFactor: number, ): CombinedPinInfo[] => { if (!actions) return []; const pinById = new Map(unitaryPins.map(p => [p.id, p])); const result: CombinedPinInfo[] = []; const combinedKeys = Object.keys(actions).filter(k => k.includes('+')); if (combinedKeys.length > 0) { console.log('[buildCombinedActionPins] combined keys in actions:', combinedKeys); console.log('[buildCombinedActionPins] unitary pin ids:', [...pinById.keys()]); } for (const [actionId, detail] of Object.entries(actions)) { if (!actionId.includes('+')) continue; const parts = actionId.split('+'); if (parts.length !== 2) continue; const [id1, id2] = parts; const pin1 = pinById.get(id1); const pin2 = pinById.get(id2); if (!pin1 || !pin2) { console.warn(`[buildCombinedActionPins] skipping "${actionId}": pin1(${id1})=${!!pin1}, pin2(${id2})=${!!pin2}`); continue; } const { midX, midY } = curveMidpoint(pin1, pin2); const severity = computeActionSeverity(detail, monitoringFactor); const label = formatPinLabel(detail); const title = formatPinTitle(`${id1} + ${id2}`, detail); result.push({ pairId: actionId, action1Id: id1, action2Id: id2, p1: { x: pin1.x, y: pin1.y }, p2: { x: pin2.x, y: pin2.y }, x: midX, y: midY, label, title, severity, }); } return result; };