import { toPng } from "html-to-image";
const PIXEL_RATIO = 3;
const BACKGROUND = "#0f172a"; // slate-900 (matches dashboard)
const DOT_COLOR = "rgba(55, 65, 81, 0.7)"; // slate-700 at 70% — matches the perceptual softness of the SVG pattern
const DOT_GAP = 30;
const DOT_SIZE = 2;
// Hard cap to avoid browser canvas memory issues. 100M pixels ≈ 800 MB RAM.
const MAX_PIXELS = 100_000_000;
export interface ExportPngOptions {
pixelRatio?: number;
backgroundColor?: string;
}
export interface ExportPngResult {
dataUrl: string;
width: number;
height: number;
}
/**
* Captures the dashboard canvas (React Flow area) as a PNG data URL.
*
* The React Flow `` component renders dots as an SVG ``,
* which html-to-image does not rasterize reliably across browsers. To get a
* deterministic output we:
* 1. Capture the React Flow area with transparent background (nodes/edges
* only) and skip the buggy SVG pattern via `filter`.
* 2. Paint our own background + dot grid onto a canvas at the correct
* pixel ratio.
* 3. Draw the captured layer on top.
*
* Overlay UI (Controls, MiniMap, EdgeLegend, anything with `data-no-export`)
* is excluded so the export is just the graph itself.
*/
export async function exportGraphAsPng(opts: ExportPngOptions = {}): Promise {
const pixelRatio = opts.pixelRatio ?? PIXEL_RATIO;
const backgroundColor = opts.backgroundColor ?? BACKGROUND;
const target = document.querySelector(".react-flow") as HTMLElement | null;
if (!target) {
throw new Error("CANVAS_NOT_FOUND");
}
const rect = target.getBoundingClientRect();
const width = Math.ceil(rect.width);
const height = Math.ceil(rect.height);
if (width === 0 || height === 0) {
throw new Error("EMPTY_GRAPH");
}
if (width * height * pixelRatio * pixelRatio > MAX_PIXELS) {
throw new Error("GRAPH_TOO_LARGE");
}
// 1. Temporarily disable CSS effects that don't translate well to a
// rasterized PNG: Tailwind's `ring-*` (box-shadow halo around rounded
// corners shows as harsh edges without backdrop-blur underneath) and
// `backdrop-filter` (browsers don't capture it at all). Restored in
// the `finally` block.
const tempStyle = document.createElement("style");
tempStyle.dataset.exportPngOverride = "true";
tempStyle.textContent = `
/* Only target service (container) nodes — group headers stay transparent
so the group's outline / border remains visible at the top. */
.react-flow__node:not(.react-flow__node-group) > * {
--tw-ring-shadow: 0 0 #0000 !important;
backdrop-filter: none !important;
/* Solid dark fill so the dot grid doesn't bleed through node bodies.
State is still indicated by the border colors and the inner state dot. */
background-color: rgb(15 23 42 / 0.85) !important;
}
`;
document.head.appendChild(tempStyle);
let nodesDataUrl: string;
try {
// 2. Capture nodes/edges with transparent background.
nodesDataUrl = await toPng(target, {
width,
height,
pixelRatio,
backgroundColor: undefined,
filter: (node) => {
// node is typed as HTMLElement but at runtime can be any Element (incl. SVG).
// We rely on Element-level APIs which exist on both HTML and SVG.
const el = node as Element;
const cl = el.classList;
if (!cl) return true;
// Custom: anything explicitly marked
if ((node as HTMLElement).dataset?.noExport === "true") return false;
// React Flow overlays
if (cl.contains("react-flow__controls")) return false;
if (cl.contains("react-flow__minimap")) return false;
if (cl.contains("react-flow__attribution")) return false;
if (cl.contains("react-flow__panel")) return false;
// We re-render the dots manually below, skip React Flow's SVG pattern.
if (cl.contains("react-flow__background")) return false;
return true;
},
});
} finally {
document.head.removeChild(tempStyle);
}
// 2. Load the captured image so we can composite it onto a canvas.
const layer = await loadImage(nodesDataUrl);
// 3. Composite: solid bg + dot grid + captured layer.
const canvas = document.createElement("canvas");
canvas.width = width * pixelRatio;
canvas.height = height * pixelRatio;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("CANVAS_CONTEXT_FAILED");
}
// Solid base
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Dot grid (matches the React Flow config: gap 30, size 2)
ctx.fillStyle = DOT_COLOR;
const gap = DOT_GAP * pixelRatio;
const radius = (DOT_SIZE * pixelRatio) / 2;
for (let x = gap; x < canvas.width; x += gap) {
for (let y = gap; y < canvas.height; y += gap) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
// Captured nodes/edges on top
ctx.drawImage(layer, 0, 0, canvas.width, canvas.height);
return { dataUrl: canvas.toDataURL("image/png"), width, height };
}
function loadImage(src: string): Promise {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error(`Image load failed: ${e}`));
img.src = src;
});
}
/**
* Triggers a browser download of a data URL with a filename of the form
* `containerflow--.png`.
*/
export function downloadPng(dataUrl: string, hostname?: string): void {
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const host = hostname?.replace(/[^a-z0-9-]/gi, "").toLowerCase() || "graph";
const filename = `containerflow-${host}-${ts}.png`;
const link = document.createElement("a");
link.href = dataUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}