Co-Study4Grid / frontend /src /utils /svg /actionPinRender.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
23.2 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
/*
* 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<SVGSVGElement, number>();
/**
* 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<string>;
rejectedActionIds?: Set<string>;
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<typeof setTimeout> | 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<string> | undefined,
rejectedIds: Set<string> | 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<string> | undefined,
rejectedIds: Set<string> | 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 <g> 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);
};