Co-Study4Grid / frontend /src /utils /svgPatch.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
13.7 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
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
//
// SVG DOM recycling — apply an N-1 or action patch to a clone of the
// N-state SVG instead of fetching and parsing a fresh ~20 MB NAD.
//
// The pypowsybl NAD always renders with identical layout, node
// positions, and element IDs when called with `fixed_positions` from
// `grid_layout.json`. That invariant lets us clone the already-loaded
// N SVGSVGElement and patch only what actually differs between states:
// - N → N-1: the contingency edge becomes dashed; flow-label
// values change on remaining edges; overload set changes.
// - N-1 → Action (non-topology-changing): flow labels change; asset
// deltas change; action-target halos are added later by
// `applyActionTargetHighlights`.
//
// Topology-changing actions (switch toggles, line reconnections, VL
// splits/merges) are caught server-side and returned as
// `{patchable: false}`; the caller must fall back to the full NAD.
//
// This module exposes two primitives that should be composed by
// `useDiagrams` and never coupled to React state directly:
// - `cloneBaseSvg(base)` — deep-clone without touching the original.
// - `applyPatchToClone(clone, metaIndex, patch)` — in-place mutation.
//
// Backup attributes:
// `data-patched-flow` — set on [foreignObject|text] nodes whose text
// we overwrite with the target-state flow label.
// Distinct from `data-original-text` (owned by
// `applyDeltaVisuals`) so delta-mode and patch
// mutations can coexist without clobbering
// each other's restore paths.
// `.nad-disconnected` — class applied to the edge shapes of
// `patch.disconnected_edges`; the CSS rule in
// App.css renders them dashed to match
// pypowsybl's native disconnected-branch look.
//
// See docs/performance/history/svg-dom-recycling.md for the full
// rationale and benchmark results.
import type { DiagramPatch, MetadataIndex } from '../types';
import { invalidateIdMapCache } from './svgUtils';
/**
* Deep-clone the pristine N-state SVGSVGElement. The N tab keeps the
* original; the returned clone is the one that gets patched and then
* injected into the N-1 or Action containers.
*/
export const cloneBaseSvg = (baseSvg: SVGSVGElement): SVGSVGElement => {
return baseSvg.cloneNode(true) as SVGSVGElement;
};
/**
* Reverse any patch mutations previously applied to `container`.
*
* Called once at the top of `applyPatchToClone` so re-applying a
* different patch on the same clone is idempotent.
*/
const resetPriorPatch = (container: Element) => {
// Restore text nodes patched with an absolute flow label.
container.querySelectorAll('[data-patched-flow]').forEach(el => {
const original = el.getAttribute('data-patched-flow');
if (original !== null) {
el.textContent = original;
}
el.removeAttribute('data-patched-flow');
});
// Drop the dashed-disconnected marker.
container.querySelectorAll('.nad-disconnected').forEach(el => {
el.classList.remove('nad-disconnected');
});
};
/**
* Format a flow value (MW or MVar) the same way pypowsybl does for NAD
* edge-info labels: rounded to whole numbers.
*/
const formatFlowValue = (value: number): string => {
if (!Number.isFinite(value)) return '0';
return Math.round(value).toString();
};
/**
* Overwrite the active-power label at a single edge terminal. Backs up
* the original text in `data-patched-flow` so the patch is reversible.
*
* `edgeInfoSvgId` is typically a `<g>` node that wraps a
* `<foreignObject>` or `<text>` with the numeric label; we descend into
* it and rewrite every leaf text/foreignObject we find (same DOM
* traversal as `applyDeltaVisuals`).
*/
const patchEdgeInfoText = (
infoEl: Element,
label: string,
) => {
infoEl.querySelectorAll('foreignObject, text').forEach(t => {
if (!t.hasAttribute('data-patched-flow')) {
t.setAttribute('data-patched-flow', t.textContent ?? '');
}
t.textContent = label;
});
};
/**
* Parse a single SVG fragment (e.g. `<g id="nad-vl-X">...</g>`) into
* an `Element` ready to splice into a live SVGSVGElement. Wraps the
* fragment in an `<svg>` with the SVG namespace so the inner element
* inherits the right namespace URI (required for subsequent
* `querySelector` calls and CSS matching).
*/
const parseSvgFragment = (fragment: string): Element | null => {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(
`<svg xmlns="http://www.w3.org/2000/svg">${fragment}</svg>`,
'image/svg+xml',
);
const first = doc.documentElement.firstElementChild;
return first ?? null;
} catch {
return null;
}
};
/**
* Splice each VL-node subtree from the patch payload into the cloned
* base diagram. For every `(vlId, { node_svg })` entry we locate the
* existing `<g id="nad-vl-{vlId}">` in the clone (via the base metadata
* index), parse the fragment, and swap the element in place. The
* fragment's `transform` attribute matches the main diagram's
* position (pypowsybl uses the same `fixed_positions`), so the splice
* is geometrically identical to a native full-NAD render for that VL.
*
* Run BEFORE the disconnected-edges and flow-label passes so those
* subsequent passes operate on the freshly-spliced DOM (their
* lookups go through the rebuilt `idMap`).
*
* Any per-VL parse / lookup failure is logged and skipped; the rest
* of the patch still applies. Global failures are caught in the
* caller, which falls back to the full-NAD endpoint.
*/
type VlSubtreeEntry = {
node_svg: string;
node_sub_svg_id: string;
edge_fragments?: Record<string, { svg: string; sub_svg_id: string }>;
};
/**
* Import a parsed fragment into the clone's document and replace
* `oldEl` with it. Rewrites the fragment's root `id` attribute to
* `targetSvgId` before insertion: pypowsybl assigns positional
* svgIds per diagram (`nad-vl-0`, `nad-l-3`, ...), so the
* sub-diagram's ids differ from the main diagram's. Without the
* rewrite, subsequent `idMap` lookups (halo clone, contingency
* halo, delta visuals) would fail to find the spliced element and
* silently skip decorating it.
*/
const spliceAndRewriteId = (
clonedSvg: SVGSVGElement,
oldEl: Element,
fragment: string,
targetSvgId: string,
idMap: Map<string, Element>,
): Element | null => {
const newEl = parseSvgFragment(fragment);
if (!newEl) return null;
try {
const imported = clonedSvg.ownerDocument!.importNode(newEl, true) as Element;
imported.setAttribute('id', targetSvgId);
oldEl.replaceWith(imported);
idMap.set(targetSvgId, imported);
return imported;
} catch (e) {
console.warn('[svgPatch] splice failed for', targetSvgId, e);
return null;
}
};
const spliceVlSubtrees = (
clonedSvg: SVGSVGElement,
baseMetaIndex: MetadataIndex,
vlSubtrees: Record<string, VlSubtreeEntry>,
idMap: Map<string, Element>,
) => {
for (const [vlId, entry] of Object.entries(vlSubtrees)) {
const nodeMeta = baseMetaIndex.nodesByEquipmentId.get(vlId);
const targetSvgId = nodeMeta?.svgId ?? `nad-vl-${vlId}`;
const oldNodeEl = idMap.get(targetSvgId);
if (!oldNodeEl) {
console.warn('[svgPatch] VL subtree splice — no live node for', vlId, targetSvgId);
continue;
}
const spliced = spliceAndRewriteId(
clonedSvg, oldNodeEl, entry.node_svg, targetSvgId, idMap,
);
if (!spliced) {
console.warn('[svgPatch] VL subtree splice — fragment parse failed for', vlId);
continue;
}
// Splice every edge fragment that pypowsybl rendered for this
// focused sub-diagram. Each one replaces the corresponding
// edge in the cloned base so the branch's piercing geometry
// at the target VL matches the new bus count.
const edgeFragments = entry.edge_fragments;
if (!edgeFragments) continue;
for (const [edgeEqId, edgeEntry] of Object.entries(edgeFragments)) {
const edgeMeta = baseMetaIndex.edgesByEquipmentId.get(edgeEqId);
if (!edgeMeta || !edgeMeta.svgId) continue;
const oldEdgeEl = idMap.get(edgeMeta.svgId);
if (!oldEdgeEl) continue;
spliceAndRewriteId(
clonedSvg, oldEdgeEl, edgeEntry.svg, edgeMeta.svgId, idMap,
);
}
}
};
/**
* Build a `Map<id, Element>` over the entire cloned SVG subtree.
*
* CRITICAL for performance: the flow-label loop touches ~2 × N edges
* (p1 + p2 terminals) on large grids — N ≈ 11 k branches on
* `bare_env_20240828T0100Z`. Doing
* `clonedSvg.querySelector('[id=...]')` inside that loop is
* O(n_dom_nodes) per call — with ~200 k DOM nodes, that's billions of
* operations and locks the browser tab (observed as "Page not
* responding" before this cache was added).
*
* One `querySelectorAll('[id]')` is O(n_dom_nodes) total; subsequent
* `Map.get` lookups are O(1) each. Overall cost drops from O(E·D) to
* O(D + E).
*/
const buildSvgIdMap = (svg: SVGSVGElement): Map<string, Element> => {
const map = new Map<string, Element>();
svg.querySelectorAll('[id]').forEach(el => map.set(el.id, el));
return map;
};
/**
* Apply `patch` to the cloned SVG in place. Returns the element and the
* metadata index the caller should associate with it in `DiagramsState`.
*
* The base-state metadata index (layout + IDs are identical across N,
* N-1, action variants) is reused verbatim — we don't rebuild it here.
*
* No-op when `patch.patchable` is false; the caller must fall back to
* the full-NAD endpoint in that case.
*/
export const applyPatchToClone = (
clonedSvg: SVGSVGElement,
baseMetaIndex: MetadataIndex,
patch: DiagramPatch,
): { svgElement: SVGSVGElement; metaIndex: MetadataIndex } => {
if (!patch.patchable) {
return { svgElement: clonedSvg, metaIndex: baseMetaIndex };
}
resetPriorPatch(clonedSvg);
const { edgesByEquipmentId } = baseMetaIndex;
const idMap = buildSvgIdMap(clonedSvg);
// 0. Splice per-VL node subtrees before anything else. When a
// node-merging / node-splitting / coupling action changed
// bus counts at one or more VLs, the backend shipped
// pypowsybl-native `<g id="nad-vl-*">` fragments; we swap
// them into the clone so the concentric multi-circle
// rendering matches the action state. No-op when the
// `vl_subtrees` field is absent or empty.
if (patch.vl_subtrees && Object.keys(patch.vl_subtrees).length > 0) {
spliceVlSubtrees(clonedSvg, baseMetaIndex, patch.vl_subtrees, idMap);
}
// 1. Mark disconnected edges (N-1 contingency line, or any extra
// edges flagged by the action patch) as dashed. The CSS rule in
// App.css renders `.nad-disconnected` with `stroke-dasharray`,
// matching pypowsybl's native disconnected-branch look.
const disconnected = patch.disconnected_edges ?? [];
for (const equipmentId of disconnected) {
const edge = edgesByEquipmentId.get(equipmentId);
if (!edge || !edge.svgId) continue;
const el = idMap.get(edge.svgId);
if (el) el.classList.add('nad-disconnected');
}
// 2. Overwrite absolute flow labels. The N SVG embeds N-state flow
// values inside each edge's edgeInfo1/edgeInfo2; the patch
// carries the target-state absolute values we must render.
const absFlows = patch.absolute_flows;
if (absFlows && absFlows.p1) {
const p1 = absFlows.p1;
const p2 = absFlows.p2 ?? {};
for (const edgeId in p1) {
const edge = edgesByEquipmentId.get(edgeId);
if (!edge) continue;
const info1Id = edge.edgeInfo1?.svgId;
if (info1Id) {
const infoEl = idMap.get(info1Id);
if (infoEl) patchEdgeInfoText(infoEl, formatFlowValue(p1[edgeId]));
}
const info2Id = edge.edgeInfo2?.svgId;
const p2Val = p2[edgeId];
if (info2Id && p2Val !== undefined) {
const infoEl = idMap.get(info2Id);
if (infoEl) patchEdgeInfoText(infoEl, formatFlowValue(p2Val));
}
}
}
// 3. Invalidate the cached idMap for any container the clone is
// about to be injected into. We don't know the container yet
// (MemoizedSvgContainer does the `replaceChildren` call), but
// the call is safe no-op until the clone is mounted. Callers
// should also `invalidateIdMapCache(container)` after mount so
// highlight passes rebuild their WeakMap against the new DOM.
const parent = clonedSvg.parentElement;
if (parent) invalidateIdMapCache(parent);
return { svgElement: clonedSvg, metaIndex: baseMetaIndex };
};