Co-Study4Grid / frontend /src /utils /svg /bitmapSnapshot.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
13 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
import type { ViewBox } from '../../types';
/**
* Bitmap snapshot of the live NAD for the opt-in "Bitmap" pan/zoom mode.
*
* The diagnostic that motivated this mode: Chrome RE-RASTERS the ~100k-node
* vector SVG layer on every CSS transform, so even the GPU-transform mode only
* buys ~1.2–1.5× (a pure ±2px translate still costs ~48ms/frame on the 5247-VL
* grid). Rasterising the SVG to a <canvas> ONCE at gesture start and
* transforming THAT bitmap during the gesture hits 120fps / 0 dropped frames
* (~6× the GPU path) because the compositor just moves a flat texture — no
* vector re-raster. See benchmarks/interaction_paint/.
*
* Two fidelity prerequisites the naive snapshot misses:
* 1. **foreignObject taint.** pypowsybl's HTML VL labels live in <foreignObject>;
* `drawImage()` of an SVG <img> containing one throws SecurityError / taints
* the canvas. They are stripped from the clone — and the gesture culls them
* anyway (`.svg-interacting`), so the bitmap matches what the user sees.
* 2. **App.css class-based paint.** Overload halos, the contingency glow and
* flow-delta colours come from App.css *stylesheet rules on classed clones*
* (see utils/svg/highlights.ts), NOT inline attributes. An SVG rendered in
* isolation as an <img> does not see the host page's stylesheet, so on the
* N-1 / Action tabs those halos would VANISH. We fix this by copying the
* relevant App.css rules + the resolved theme tokens into a <style> inside
* the clone, plus the current `data-zoom-tier` so the tier-capped halo
* widths render correctly.
*/
const SVG_NS = 'http://www.w3.org/2000/svg';
/** Theme tokens referenced by the highlight / delta / edge-info App.css rules. */
const TOKEN_NAMES = [
'--signal-overload', '--signal-action-target', '--signal-contingency',
'--signal-delta-positive', '--signal-delta-negative', '--signal-delta-neutral',
'--color-text-primary', '--color-diagram-surface', '--color-danger',
];
/** Class fragments whose App.css rules paint things absent from inline attrs. */
const SELECTOR_HINTS = [
'nad-overloaded', 'nad-action-target', 'nad-contingency-highlight',
'nad-delta-', 'nad-disconnected', 'nad-highlight', 'nad-edge-infos', 'nad-active',
];
/**
* Collect the App.css rules + resolved theme tokens needed so a detached clone
* paints its halos / deltas / edge-info exactly as the live diagram does.
* Returns a CSS string to inline into the clone's own <style>.
*/
export const collectHighlightCss = (doc: Document = document): string => {
const view = doc.defaultView;
let tokenCss = '';
try {
const cs = view?.getComputedStyle(doc.documentElement);
if (cs) {
const decls = TOKEN_NAMES
.map(n => { const v = cs.getPropertyValue(n).trim(); return v ? `${n}:${v}` : ''; })
.filter(Boolean)
.join(';');
if (decls) tokenCss = `svg{${decls}}`;
}
} catch { /* getComputedStyle unavailable */ }
// The live diagram's strokes are kept at constant SCREEN width by the
// base `.svg-container svg {path,line,polyline,rect}` non-scaling-stroke
// rule (App.css). That rule is scoped to `.svg-container` so it does NOT
// travel into the detached clone — without re-asserting it here, every
// branch line and flow-delta stroke renders at its USER-space width, which
// is sub-pixel at the base viewBox (≈ invisible). Re-assert it (low
// specificity, so the !important halo overrides still win).
let rulesCss = 'path,line,polyline,rect{vector-effect:non-scaling-stroke}\n';
try {
for (const sheet of Array.from(doc.styleSheets)) {
let rules: CSSRuleList | null = null;
try { rules = sheet.cssRules; } catch { continue; } // cross-origin sheet
if (!rules) continue;
for (const rule of Array.from(rules)) {
const sel = (rule as CSSStyleRule).selectorText;
if (sel && SELECTOR_HINTS.some(h => sel.includes(h))) {
rulesCss += (rule as CSSStyleRule).cssText + '\n';
}
}
}
} catch { /* styleSheets unavailable */ }
return tokenCss ? tokenCss + '\n' + rulesCss : rulesCss;
};
export interface SnapshotOptions {
/** viewBox baked on the live SVG at gesture start. */
baseVb: ViewBox;
/** Element CSS box (untransformed) — the raster surface size. */
width: number;
height: number;
/** Current `data-zoom-tier` so tier-dependent rules render right. */
zoomTier?: string | null;
/**
* Current zoom-adaptive halo width (px) from usePanZoom's `--nad-halo-w`.
* The inlined halo rules bind `stroke-width: var(--nad-halo-w, 24px)`, and
* the isolated snapshot SVG has no JS to set the var — so we re-declare it
* on the snapshot root here, otherwise the baked halo would snap to the
* 24px fallback and mismatch the live (e.g. zoomed-out 120px) halo.
*/
haloWidthPx?: number | null;
/** CSS (from collectHighlightCss) to inline so halos/deltas keep painting. */
css?: string;
}
/**
* CSS that re-declares the live `--nad-halo-w` on the snapshot's <svg> root so
* the inlined `var(--nad-halo-w, …)` halo widths resolve to the current zoom's
* value (custom properties inherit to the cloned halo descendants).
*/
const haloVarRule = (opts: SnapshotOptions): string =>
opts.haloWidthPx != null ? `svg{--nad-halo-w:${opts.haloWidthPx}px}\n` : '';
/**
* Build a detached, de-tainted, style-inlined clone of the live NAD svg,
* ready to rasterise. Pure DOM work — unit-testable in jsdom.
*/
export const buildSnapshotSvg = (liveSvg: SVGSVGElement, opts: SnapshotOptions): SVGSVGElement => {
const clone = liveSvg.cloneNode(true) as SVGSVGElement;
// Strip HTML <foreignObject> labels (canvas taint / SecurityError on draw).
clone.querySelectorAll('foreignObject').forEach(n => n.remove());
// Drop any live interaction transform that may sit on the root.
if (clone.style) { clone.style.transform = ''; clone.style.willChange = ''; }
clone.setAttribute('width', String(opts.width));
clone.setAttribute('height', String(opts.height));
clone.setAttribute('viewBox', `${opts.baseVb.x} ${opts.baseVb.y} ${opts.baseVb.w} ${opts.baseVb.h}`);
if (opts.zoomTier) clone.setAttribute('data-zoom-tier', opts.zoomTier);
if (opts.css) {
const style = (clone.ownerDocument || document).createElementNS(SVG_NS, 'style');
style.textContent = haloVarRule(opts) + opts.css;
clone.insertBefore(style, clone.firstChild);
}
return clone;
};
/**
* Rasterise a (detached) svg element onto a fresh dpr-scaled <canvas>.
* Async (the SVG must decode as an Image first). Browser-only — the caller
* guards the jsdom/test path.
*/
export const rasterizeSvgToCanvas = async (
svg: SVGSVGElement, width: number, height: number, dpr: number,
): Promise<HTMLCanvasElement> => {
const xml = new XMLSerializer().serializeToString(svg);
const url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
try {
const img = new Image();
img.width = width;
img.height = height;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('snapshot image decode failed'));
img.src = url;
});
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('no 2d context for snapshot');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.drawImage(img, 0, 0, width, height);
return canvas;
} finally {
URL.revokeObjectURL(url);
}
};
/** One-shot: clone + de-taint + style-inline + rasterise → dpr-scaled canvas. */
export const createNadSnapshotCanvas = async (
liveSvg: SVGSVGElement,
opts: SnapshotOptions & { dpr: number },
): Promise<HTMLCanvasElement> => {
const clone = buildSnapshotSvg(liveSvg, opts);
return rasterizeSvgToCanvas(clone, opts.width, opts.height, opts.dpr);
};
// ---------------------------------------------------------------------------
// Cached / deferred path (keeps the gesture start responsive)
// ---------------------------------------------------------------------------
// Serialising the 9 MB / ~100k-node NAD costs ~250-300 ms on the main thread —
// far too much to run inside the mousedown handler (it froze the pan start).
// So we (1) serialise ONCE per diagram state and cache the string (this is the
// viewBox-independent part), (2) re-build only the cheap `<svg>` header +
// inlined <style> per gesture, and (3) decode off the main thread via
// createImageBitmap. The caller pre-warms the cache on idle and invalidates it
// via a MutationObserver, so a gesture's snapshot is usually a cheap string
// compose + an off-thread decode.
/**
* Serialise an FO-stripped clone of the live SVG to a string (the expensive,
* viewBox-independent part — cache this and re-use across gestures until the
* DOM changes). No width/viewBox/style baked in — `composeSnapshotMarkup` adds
* those per gesture.
*/
export const serializeStrippedSvg = (liveSvg: SVGSVGElement): string => {
const clone = liveSvg.cloneNode(true) as SVGSVGElement;
clone.querySelectorAll('foreignObject').forEach(n => n.remove());
if (clone.style) { clone.style.transform = ''; clone.style.willChange = ''; clone.style.visibility = ''; }
return new XMLSerializer().serializeToString(clone);
};
/**
* Cheap per-gesture step: take the cached serialisation and override the root
* `<svg>` width/height/viewBox/data-zoom-tier (preserving its namespaces) and
* inject the highlight CSS — without re-serialising the body.
*/
export const composeSnapshotMarkup = (serialized: string, opts: SnapshotOptions): string => {
const m = serialized.match(/<svg\b[^>]*>/);
if (!m) return serialized;
let open = m[0];
const setAttr = (s: string, name: string, val: string | number): string =>
new RegExp(`\\b${name}="[^"]*"`).test(s)
? s.replace(new RegExp(`\\b${name}="[^"]*"`), `${name}="${val}"`)
: s.replace(/^<svg\b/, `<svg ${name}="${val}"`);
open = setAttr(open, 'width', opts.width);
open = setAttr(open, 'height', opts.height);
open = setAttr(open, 'viewBox', `${opts.baseVb.x} ${opts.baseVb.y} ${opts.baseVb.w} ${opts.baseVb.h}`);
open = setAttr(open, 'preserveAspectRatio', 'xMidYMid meet');
if (opts.zoomTier) open = setAttr(open, 'data-zoom-tier', opts.zoomTier);
const styleTag = opts.css ? `<style>${haloVarRule(opts)}${opts.css}</style>` : '';
return serialized.replace(/<svg\b[^>]*>/, open + styleTag);
};
/**
* Rasterise an SVG markup string onto a dpr-scaled canvas. Prefers
* `createImageBitmap` (off-main-thread decode → no jank); falls back to an
* `<Image>` blob decode where it's unavailable / rejects (e.g. older Safari).
*/
export const rasterizeMarkupToCanvas = async (
markup: string, width: number, height: number, dpr: number,
): Promise<HTMLCanvasElement> => {
const blob = new Blob([markup], { type: 'image/svg+xml;charset=utf-8' });
let source: CanvasImageSource | null = null;
let bitmap: ImageBitmap | null = null;
let url: string | null = null;
if (typeof createImageBitmap === 'function') {
try { bitmap = await createImageBitmap(blob); source = bitmap; } catch { source = null; }
}
if (!source) {
url = URL.createObjectURL(blob);
const img = new Image();
img.width = width; img.height = height;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('snapshot image decode failed'));
img.src = url as string;
});
source = img;
}
try {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('no 2d context for snapshot');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.drawImage(source, 0, 0, width, height);
return canvas;
} finally {
if (url) URL.revokeObjectURL(url);
if (bitmap) bitmap.close();
}
};