Files
pvemanager/frontend/src/features/topology/lib/export.ts
markmorado 917cc0a5bb
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m22s
fix: correct topology export bounds, edge rendering, and switch to top-down layout
- Export: use useReactFlow().getNodesBounds() (measured dimensions) instead
  of the standalone helper, which treated every node's size as zero and
  clipped the last row/column of the exported image
- Export: give edge paths an explicit inline stroke before capture -
  html-to-image clones <svg> natively and skips computed-style inlining for
  it, so edges came out with no stroke at all
- Enable the edge flow animation (was hardcoded off)
- Switch the graph from left-to-right to top-to-bottom, including flipping
  every node's handle positions, and recolor edges from the barely-visible
  border tone to the primary accent
2026-08-20 04:50:41 +05:00

103 lines
4.2 KiB
TypeScript

import { getViewportForBounds, type Rect } from '@xyflow/react';
import { toPng, toSvg } from 'html-to-image';
const MAX_SIDE = 4096;
/**
* Saves the graph as an image.
*
* Custom nodes are HTML, so a native SVG serialisation is impossible — the SVG
* variant wraps the markup in `<foreignObject>`, which browsers render fine but
* vector editors only partially. PNG is the default for that reason.
*
* The capture always covers the whole graph rather than the current viewport,
* which is what people expect from an export button.
*
* `bounds` must come from the `useReactFlow().getNodesBounds()` hook, not the
* standalone `getNodesBounds` helper - the standalone version has no access to
* each node's measured width/height and silently treats them as zero, which
* undersizes the canvas and clips the last row/column of the graph.
*/
export async function exportGraph(
bounds: Rect,
hasNodes: boolean,
format: 'png' | 'svg',
filename: string,
): Promise<void> {
const viewport = document.querySelector<HTMLElement>('.react-flow__viewport');
if (!viewport || !hasNodes) return;
// Edges live inside a nested <svg> (`.react-flow__edges`). html-to-image
// clones any <svg> with the browser's native `cloneNode(true)` instead of
// its own computed-style-inlining walk (see cloneChildren in
// html-to-image/lib/clone-node.js, which returns early for svg elements),
// so nothing that comes from an external stylesheet - including React
// Flow's own `.react-flow__edge-path { stroke: var(--xy-edge-stroke) }` -
// survives into the clone. The path has no inline style of its own, so the
// exported edge ends up with no stroke at all. Give every edge element an
// explicit inline stroke (its current resolved color) right before
// capture, then revert once the capture is done.
const edgeLayer = viewport.querySelector<SVGElement>('.react-flow__edges');
const restoreEdgeStyles: Array<() => void> = [];
if (edgeLayer) {
const styledEls = edgeLayer.querySelectorAll<SVGElement>('path, circle, polygon, rect');
styledEls.forEach((el) => {
const computed = getComputedStyle(el);
const prevStroke = el.style.stroke;
const prevFill = el.style.fill;
const stroke = computed.stroke;
const fill = computed.fill;
if (stroke && stroke !== 'none') el.style.stroke = stroke;
if (fill && fill !== 'none') el.style.fill = fill;
restoreEdgeStyles.push(() => {
el.style.stroke = prevStroke;
el.style.fill = prevFill;
});
});
}
const padding = 40;
// Size the canvas to the graph itself, not an arbitrary minimum - forcing a
// larger canvas than the content needs a zoom above the 2x cap to fill it,
// so small graphs end up tiny in a mostly-empty image.
const width = Math.min(MAX_SIDE, Math.max(1, Math.round(bounds.width + padding * 2)));
const height = Math.min(MAX_SIDE, Math.max(1, Math.round(bounds.height + padding * 2)));
// getViewportForBounds's padding is a fraction of width/height unless given
// as a "px" string - a bare number here (e.g. 40) is read as 4000% padding,
// which collapses the zoom to the floor and leaves the graph tiny.
const transform = getViewportForBounds(bounds, width, height, 0.2, 2, `${padding}px`);
const background =
getComputedStyle(document.documentElement).getPropertyValue('--background').trim() || '#0a0a0a';
const options = {
backgroundColor: background,
width,
height,
style: {
width: `${width}px`,
height: `${height}px`,
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`,
},
// Controls and the minimap are UI chrome, not part of the diagram.
filter: (element: HTMLElement) =>
!element?.classList?.contains?.('react-flow__minimap') &&
!element?.classList?.contains?.('react-flow__controls') &&
!element?.dataset?.exportIgnore,
cacheBust: true,
pixelRatio: format === 'png' ? 2 : 1,
};
let dataUrl: string;
try {
dataUrl = format === 'png' ? await toPng(viewport, options) : await toSvg(viewport, options);
} finally {
restoreEdgeStyles.forEach((restore) => restore());
}
const link = document.createElement('a');
link.download = `${filename}.${format}`;
link.href = dataUrl;
link.click();
}