// 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 RENDER layer — DOM injection on top of the * NAD SVG. Pure-data helpers live in `actionPinData.ts` and are * re-used here. */ import type { MetadataIndex } from '../../types'; import { pinChrome } from '../../styles/tokens'; import { getIdMap } from './idMap'; import { severityFill, severityFillDimmed, severityFillHighlighted, type ActionPinInfo, type CombinedPinInfo, } from './actionPinData'; // Unitary-pin glyph is built by the shared pure-JS module so the // overflow-graph iframe overlay (`expert_backend/services/ // overflow_overlay.py`) can render visually identical pins by // reading the same source. import { createPinGlyph as createSharedPinGlyph } from './pinGlyph'; /** * Apply contingency + overload halo highlights to the * action-overview diagram, mirroring what the N-1 tab shows. */ export const applyActionOverviewHighlights = ( container: HTMLElement | null, metaIndex: MetadataIndex | null, contingency: string | null, overloadedLines: readonly string[], ) => { if (!container) return; const svg = container.querySelector('svg') as SVGSVGElement | null; if (!svg) return; // Wipe any existing overview highlight layer — keeps repeated // calls idempotent. svg.querySelectorAll('.nad-overview-highlight-layer').forEach(el => el.remove()); if (!metaIndex) return; const haveContingency = !!contingency; const haveOverloads = overloadedLines.length > 0; if (!haveContingency && !haveOverloads) return; const SVG_NS = 'http://www.w3.org/2000/svg'; const layer = document.createElementNS(SVG_NS, 'g') as SVGGElement; layer.setAttribute('class', 'nad-overview-highlight-layer'); if (svg.firstChild) { svg.insertBefore(layer, svg.firstChild); } else { svg.appendChild(layer); } const idMap = getIdMap(container); // --- BATCHED READ/WRITE PATTERN --- // Phase 1: collect all edge ids to highlight (pure data — no DOM reads). const edgesToClone: { svgId: string; klass: string }[] = []; if (contingency) { const edge = metaIndex.edgesByEquipmentId.get(contingency); if (edge?.svgId) edgesToClone.push({ svgId: edge.svgId, klass: 'nad-contingency-highlight' }); } overloadedLines.forEach(name => { const edge = metaIndex.edgesByEquipmentId.get(name); if (edge?.svgId) edgesToClone.push({ svgId: edge.svgId, klass: 'nad-overloaded' }); }); if (edgesToClone.length === 0) return; // Phase 2: READ — clone nodes and read all CTMs in a single // contiguous pass (no DOM writes in between, avoiding layout thrashing). let layerCTM: DOMMatrix | null = null; try { layerCTM = (layer as SVGGraphicsElement).getScreenCTM?.() ?? null; } catch { /* jsdom */ } const prepared: { clone: SVGGraphicsElement; transform: string | null }[] = []; for (const { svgId, klass } of edgesToClone) { const original = idMap.get(svgId); if (!original) continue; const clone = original.cloneNode(true) as SVGGraphicsElement; clone.removeAttribute('id'); clone.classList.add(klass, 'nad-highlight-clone'); clone.classList.remove('nad-delta-positive', 'nad-delta-negative', 'nad-delta-grey'); let transform: string | null = null; try { const origCTM = (original as SVGGraphicsElement).getScreenCTM?.(); if (origCTM && layerCTM) { const m = layerCTM.inverse().multiply(origCTM); transform = `matrix(${m.a}, ${m.b}, ${m.c}, ${m.d}, ${m.e}, ${m.f})`; } } catch { /* jsdom */ } prepared.push({ clone, transform }); } // Phase 3: WRITE — batch all DOM mutations via a DocumentFragment // to trigger a single reflow instead of one per clone. const frag = document.createDocumentFragment(); for (const { clone, transform } of prepared) { if (transform) clone.setAttribute('transform', transform); frag.appendChild(clone); } layer.appendChild(frag); }; /** * Read a sensible base radius for the pin glyph from the SVG. * * We want pins to be "similar in size to voltage-level circles when * zoomed" (as the operator sees other highlights in the NAD), so we * pick up the radius of the first VL circle in the diagram and use * it as the pin body radius. Falls back to 30 user units when the * SVG has no circles (e.g. a handcrafted test fixture). */ export const readPinBaseRadius = (svg: SVGSVGElement): number => { const vlCircle = svg.querySelector('.nad-vl-nodes circle[r]') ?? svg.querySelector('circle[r]'); if (vlCircle) { const attr = vlCircle.getAttribute('r'); const n = attr ? parseFloat(attr) : NaN; if (Number.isFinite(n) && n > 0) return n; } return 30; }; /** * Minimum pin body radius in SCREEN pixels. Enforced by * {@link rescaleActionOverviewPins} so pins remain readable when the * operator zooms far out. */ const PIN_MIN_SCREEN_RADIUS_PX = 22; /** * Minimum pin body radius as a fraction of the current viewBox * extent. On large grids the VL circle radius is tiny relative to * the diagram; this floor keeps pins prominent at initial auto-fit. */ const PIN_VIEWBOX_FRACTION = 50; /** * Cache the base radius per SVG element so `rescaleActionOverviewPins` * (called on every rAF during zoom) skips the querySelectorAll lookup. */ const pinBaseRadiusCache = new WeakMap(); /** * Pure helper for rescale math — exported so tests can pin it down * without exercising the DOM mutation side. */ export const computePinScale = ( baseR: number, pxPerSvgUnit: number, viewBoxMax: number, ): { effectiveR: number; scale: number } => { const minSvgR = PIN_MIN_SCREEN_RADIUS_PX / pxPerSvgUnit; const viewBoxMinR = viewBoxMax / PIN_VIEWBOX_FRACTION; const effectiveR = Math.max(baseR, minSvgR, viewBoxMinR); return { effectiveR, scale: effectiveR / baseR }; }; export const rescaleActionOverviewPins = (container: HTMLElement | null) => { if (!container) return; const svg = container.querySelector('svg') as SVGSVGElement | null; if (!svg) return; const layer = svg.querySelector('.nad-action-overview-pins'); if (!layer) return; performance.mark('aod:rescalePins:start'); // Derive pxPerSvgUnit from the viewBox width and the container's // client width — pure math, no forced layout. let pxPerSvgUnit = 1; let viewBoxMax = 0; const vbAttr = svg.getAttribute('viewBox'); if (vbAttr) { const parts = vbAttr.split(/[\s,]+/).map(Number); if (parts.length === 4 && Number.isFinite(parts[2]) && parts[2] > 0) { const containerW = container.clientWidth; if (containerW > 0) { pxPerSvgUnit = containerW / parts[2]; } else { const ctm = (svg as SVGGraphicsElement).getScreenCTM?.(); if (ctm) pxPerSvgUnit = ctm.a; } viewBoxMax = Math.max(parts[2], parts[3]); } } let baseR = pinBaseRadiusCache.get(svg); if (baseR === undefined) { baseR = readPinBaseRadius(svg); pinBaseRadiusCache.set(svg, baseR); } const { scale } = computePinScale(baseR, pxPerSvgUnit, viewBoxMax); layer.querySelectorAll('.nad-action-overview-pin-body').forEach(body => { body.setAttribute('transform', `scale(${scale})`); }); performance.mark('aod:rescalePins:end'); const entry = performance.measure('aod:rescalePins', 'aod:rescalePins:start', 'aod:rescalePins:end'); if (entry.duration > 5) console.log(`[PERF] aod:rescalePins: ${entry.duration.toFixed(2)}ms`); }; /** * Delay (ms) used by {@link applyActionOverviewPins} to distinguish * a pin single-click from the first click of a double-click. */ export const PIN_SINGLE_CLICK_DELAY_MS = 250; /** * Build a 5-pointed star SVG path string centred at (cx, cy). * Exported so unit tests can verify the generator stays stable. */ export const starPath = (cx: number, cy: number, outerR: number): string => { const innerR = outerR * 0.4; const pts: string[] = []; for (let i = 0; i < 10; i++) { const angle = (Math.PI / 2) + (i * Math.PI / 5); const r = i % 2 === 0 ? outerR : innerR; pts.push(`${cx + r * Math.cos(angle)},${cy - r * Math.sin(angle)}`); } return `M ${pts.join(' L ')} Z`; }; /** * Build an X (cross) SVG path string centred at (cx, cy) with the * given half-width. Used as the "rejected" status symbol. */ export const crossPath = (cx: number, cy: number, halfW: number): string => { const t = halfW * 0.25; // arm thickness return [ `M ${cx - halfW} ${cy - halfW + t}`, `L ${cx - t} ${cy}`, `L ${cx - halfW} ${cy + halfW - t}`, `L ${cx - halfW + t} ${cy + halfW}`, `L ${cx} ${cy + t}`, `L ${cx + halfW - t} ${cy + halfW}`, `L ${cx + halfW} ${cy + halfW - t}`, `L ${cx + t} ${cy}`, `L ${cx + halfW} ${cy - halfW + t}`, `L ${cx + halfW - t} ${cy - halfW}`, `L ${cx} ${cy - t}`, `L ${cx - halfW + t} ${cy - halfW}`, 'Z', ].join(' '); }; /** Options bag for {@link applyActionOverviewPins}. */ export interface ApplyPinsOptions { selectedActionIds?: Set; rejectedActionIds?: Set; combinedPins?: readonly CombinedPinInfo[]; /** * Dimmed, dashed pins for scored-but-not-yet-simulated actions. * Rendered with their own double-click handler * ({@link onUnsimulatedPinDoubleClick}) so that a drill-down gesture * on one of them kicks off a manual simulation instead of the * action-variant view. */ unsimulatedPins?: readonly ActionPinInfo[]; /** Fires when an unsimulated pin is double-clicked. */ onUnsimulatedPinDoubleClick?: (actionId: string) => void; } const SVG_NS = 'http://www.w3.org/2000/svg'; const attachPinClickListeners = ( g: SVGGElement, pinId: string, clickCb: (actionId: string, screenPos: { x: number; y: number }) => void, dblClickCb: ((actionId: string) => void) | undefined, ) => { let clickTimer: ReturnType | null = null; g.addEventListener('mousedown', (evt) => { evt.stopPropagation(); }); g.addEventListener('click', (evt) => { evt.stopPropagation(); if (clickTimer !== null) return; const rect = (evt.currentTarget as SVGGElement).getBoundingClientRect(); const screenPos = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; clickTimer = setTimeout(() => { clickTimer = null; clickCb(pinId, screenPos); }, PIN_SINGLE_CLICK_DELAY_MS); }); g.addEventListener('dblclick', (evt) => { evt.stopPropagation(); if (clickTimer !== null) { clearTimeout(clickTimer); clickTimer = null; } if (dblClickCb) dblClickCb(pinId); }); }; const buildPinGlyph = ( body: SVGGElement, R: number, labelFont: number, fill: string, label: string, titleText: string, strokeColor?: string, strokeWidth?: number, ) => { const tail = R * 0.9; const title = document.createElementNS(SVG_NS, 'title'); title.textContent = titleText; body.appendChild(title); const path = document.createElementNS(SVG_NS, 'path'); const d = `M ${-R} ${-R - tail} A ${R} ${R} 0 1 1 ${R} ${-R - tail} L 0 0 Z`; path.setAttribute('d', d); path.setAttribute('fill', fill); if (strokeColor) { path.setAttribute('stroke', strokeColor); path.setAttribute('stroke-width', String(strokeWidth ?? R * 0.12)); } else { path.setAttribute('stroke', 'none'); } body.appendChild(path); const inner = document.createElementNS(SVG_NS, 'circle'); inner.setAttribute('cx', '0'); inner.setAttribute('cy', String(-R - tail)); inner.setAttribute('r', String(R * 0.72)); inner.setAttribute('fill', pinChrome.glyphBg); inner.setAttribute('fill-opacity', '0.92'); inner.setAttribute('pointer-events', 'none'); body.appendChild(inner); const text = document.createElementNS(SVG_NS, 'text'); text.setAttribute('x', '0'); text.setAttribute('y', String(-R - tail)); text.setAttribute('text-anchor', 'middle'); text.setAttribute('dominant-baseline', 'central'); text.setAttribute('font-size', String(labelFont)); text.setAttribute('font-weight', '800'); text.setAttribute('font-family', 'system-ui, -apple-system, Arial, sans-serif'); text.setAttribute('fill', pinChrome.glyphText); text.setAttribute('pointer-events', 'none'); text.textContent = label; body.appendChild(text); }; const resolvePinFill = ( severity: ActionPinInfo['severity'], isSelected: boolean, isRejected: boolean, isDimmedByFilter: boolean = false, ): { fill: string; stroke?: string } => { if (isSelected) return { fill: severityFillHighlighted[severity], stroke: pinChrome.gold }; // Filter-dimmed constituents reuse the same washed-out palette as // rejected pins so the operator reads them as "context, not a // first-class action" at a glance. if (isRejected || isDimmedByFilter) return { fill: severityFillDimmed[severity] }; return { fill: severityFill[severity] }; }; const renderCombinedPin = ( frag: DocumentFragment, cp: CombinedPinInfo, r: number, labelFont: number, edgeStrokeW: number, selectedIds: Set | undefined, rejectedIds: Set | undefined, onPinClick: (actionId: string, screenPos: { x: number; y: number }) => void, onPinDoubleClick: ((actionId: string) => void) | undefined, ) => { // Curved connection line between the two unitary pins. const dx = cp.p2.x - cp.p1.x; const dy = cp.p2.y - cp.p1.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const offsetFraction = 0.3; const ctrlX = (cp.p1.x + cp.p2.x) / 2 + (-dy / dist) * dist * offsetFraction; const ctrlY = (cp.p1.y + cp.p2.y) / 2 + (dx / dist) * dist * offsetFraction; const curvePath = document.createElementNS(SVG_NS, 'path'); curvePath.setAttribute('d', `M ${cp.p1.x} ${cp.p1.y} Q ${ctrlX} ${ctrlY} ${cp.p2.x} ${cp.p2.y}`); curvePath.setAttribute('class', 'nad-combined-action-curve'); curvePath.setAttribute('fill', 'none'); curvePath.setAttribute('stroke', severityFill[cp.severity]); curvePath.setAttribute('stroke-width', String(edgeStrokeW)); curvePath.setAttribute('stroke-dasharray', `${edgeStrokeW * 2.5} ${edgeStrokeW * 1.5}`); curvePath.setAttribute('stroke-linecap', 'round'); curvePath.setAttribute('pointer-events', 'none'); frag.appendChild(curvePath); const g = document.createElementNS(SVG_NS, 'g'); g.setAttribute('class', 'nad-action-overview-pin nad-combined-action-pin'); g.setAttribute('transform', `translate(${cp.x} ${cp.y})`); g.setAttribute('data-action-id', cp.pairId); (g as SVGGElement).style.cursor = 'pointer'; const body = document.createElementNS(SVG_NS, 'g'); body.setAttribute('class', 'nad-action-overview-pin-body'); body.setAttribute('transform', 'scale(1)'); g.appendChild(body); const { fill, stroke } = resolvePinFill(cp.severity, selectedIds?.has(cp.pairId) ?? false, rejectedIds?.has(cp.pairId) ?? false); buildPinGlyph(body, r, labelFont, fill, cp.label, cp.title, stroke, stroke ? r * 0.12 : undefined); // "+" badge on the bubble top to indicate it's a combined pin. const tail = r * 0.9; const badgeCy = -r - tail - r * 0.95; const badge = document.createElementNS(SVG_NS, 'circle'); badge.setAttribute('cx', '0'); badge.setAttribute('cy', String(badgeCy)); badge.setAttribute('r', String(r * 0.35)); badge.setAttribute('fill', severityFill[cp.severity]); badge.setAttribute('stroke', 'white'); badge.setAttribute('stroke-width', String(r * 0.06)); badge.setAttribute('pointer-events', 'none'); body.appendChild(badge); const plusText = document.createElementNS(SVG_NS, 'text'); plusText.setAttribute('x', '0'); plusText.setAttribute('y', String(badgeCy)); plusText.setAttribute('text-anchor', 'middle'); plusText.setAttribute('dominant-baseline', 'central'); plusText.setAttribute('font-size', String(r * 0.5)); plusText.setAttribute('font-weight', '900'); plusText.setAttribute('font-family', 'system-ui, -apple-system, Arial, sans-serif'); plusText.setAttribute('fill', 'white'); plusText.setAttribute('pointer-events', 'none'); plusText.textContent = '+'; body.appendChild(plusText); attachPinClickListeners(g, cp.pairId, onPinClick, onPinDoubleClick); frag.appendChild(g); }; const renderUnitaryPin = ( frag: DocumentFragment, pin: ActionPinInfo, r: number, labelFont: number, selectedIds: Set | undefined, rejectedIds: Set | undefined, onPinClick: (actionId: string, screenPos: { x: number; y: number }) => void, onPinDoubleClick: ((actionId: string) => void) | undefined, ) => { const isSelected = selectedIds?.has(pin.id) ?? false; const isRejected = rejectedIds?.has(pin.id) ?? false; // Shared glyph factory used by both the overview tab and the // overflow-graph iframe overlay. Returns the inner already // populated with body, chrome, label and the selected/rejected // status symbol. const g = createSharedPinGlyph(document, { severity: pin.severity, label: pin.label, title: pin.title, actionId: pin.id, isSelected, isRejected, dimmed: !!pin.dimmedByFilter, r, labelFont, bodyClass: 'nad-action-overview-pin-body', }) as SVGGElement; // Wrap-up customisation specific to the overview layer. g.setAttribute('class', 'nad-action-overview-pin'); g.setAttribute('transform', `translate(${pin.x} ${pin.y})`); if (pin.dimmedByFilter) g.setAttribute('data-dimmed-by-filter', 'true'); g.style.cursor = 'pointer'; attachPinClickListeners(g, pin.id, onPinClick, onPinDoubleClick); frag.appendChild(g); }; /** * Render a scored-but-not-simulated action pin. Rendered with a dashed * stroke + reduced opacity so it reads as a "preview" glyph the * operator can promote to a real simulation with a double-click. * Uses the 'grey' severity palette since we have no rho_after yet. */ const renderUnsimulatedPin = ( frag: DocumentFragment, pin: ActionPinInfo, r: number, labelFont: number, onPinClick: (actionId: string, screenPos: { x: number; y: number }) => void, onUnsimulatedPinDoubleClick: ((actionId: string) => void) | undefined, onPinDoubleClick: ((actionId: string) => void) | undefined, ) => { const g = document.createElementNS(SVG_NS, 'g'); g.setAttribute('class', 'nad-action-overview-pin nad-action-overview-pin-unsimulated'); g.setAttribute('transform', `translate(${pin.x} ${pin.y})`); g.setAttribute('data-action-id', pin.id); g.setAttribute('data-unsimulated', 'true'); g.setAttribute('opacity', '0.5'); (g as SVGGElement).style.cursor = 'pointer'; const body = document.createElementNS(SVG_NS, 'g'); body.setAttribute('class', 'nad-action-overview-pin-body'); body.setAttribute('transform', 'scale(1)'); g.appendChild(body); const fill = severityFillDimmed[pin.severity]; buildPinGlyph(body, r, labelFont, fill, pin.label, pin.title, pinChrome.strokeNeutral, r * 0.08); // Override the stroke with a dashed pattern to visually // distinguish un-simulated pins from the solid-outline simulated // ones. const pinPath = body.querySelector('path'); if (pinPath) { pinPath.setAttribute('stroke-dasharray', `${r * 0.35} ${r * 0.2}`); } // Double-click runs a manual simulation; single click still opens // the (minimal) preview popover via onPinClick so the operator // gets identical affordances to the simulated pins. attachPinClickListeners( g, pin.id, onPinClick, onUnsimulatedPinDoubleClick ?? onPinDoubleClick, ); frag.appendChild(g); }; /** * Inject (or refresh) the action-overview pin layer inside the * given container's SVG. Calling this with an empty `pins` array and * no combined pins wipes the layer. */ export const applyActionOverviewPins = ( container: HTMLElement | null, pins: ActionPinInfo[], onPinClick: (actionId: string, screenPos: { x: number; y: number }) => void, onPinDoubleClick?: (actionId: string) => void, opts?: ApplyPinsOptions, ) => { if (!container) return; const svg = container.querySelector('svg') as SVGSVGElement | null; if (!svg) return; // Purge any existing layer so repeated calls stay idempotent. svg.querySelectorAll('.nad-action-overview-pins').forEach(el => el.remove()); const combinedPins = opts?.combinedPins ?? []; const unsimulatedPins = opts?.unsimulatedPins ?? []; const onUnsimulatedPinDoubleClick = opts?.onUnsimulatedPinDoubleClick; if (pins.length === 0 && combinedPins.length === 0 && unsimulatedPins.length === 0) return; const selectedIds = opts?.selectedActionIds; const rejectedIds = opts?.rejectedActionIds; const r = readPinBaseRadius(svg); pinBaseRadiusCache.set(svg, r); const labelFont = Math.max(9, r * 0.8); // Read the actual edge stroke width from the network SVG so // combined-action curves match the underlying edge thickness. let edgeStrokeW = 3; const edgePath = svg.querySelector('.nad-edge-paths path[style*="stroke-width"], .nad-edge-paths path') as SVGElement | null; if (edgePath) { const sw = edgePath.style?.strokeWidth || edgePath.getAttribute('stroke-width'); if (sw) { const n = parseFloat(sw); if (Number.isFinite(n) && n > 0) edgeStrokeW = n; } } const layer = document.createElementNS(SVG_NS, 'g'); layer.setAttribute('class', 'nad-action-overview-pins'); const frag = document.createDocumentFragment(); combinedPins.forEach(cp => renderCombinedPin(frag, cp, r, labelFont, edgeStrokeW, selectedIds, rejectedIds, onPinClick, onPinDoubleClick), ); pins.forEach(pin => renderUnitaryPin(frag, pin, r, labelFont, selectedIds, rejectedIds, onPinClick, onPinDoubleClick), ); unsimulatedPins.forEach(pin => renderUnsimulatedPin(frag, pin, r, labelFont, onPinClick, onUnsimulatedPinDoubleClick, onPinDoubleClick), ); layer.appendChild(frag); svg.appendChild(layer); // Apply the initial scale compensation so the pins come up at // the right size on the very first paint. rescaleActionOverviewPins(container); };