"use client"; import type { ChartData } from "../../lib/chartAggregate"; import { toCsv } from "../../lib/chartAggregate"; function downloadBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } export function exportCsv(data: ChartData, filename: string): void { downloadBlob( new Blob([toCsv(data)], { type: "text/csv;charset=utf-8" }), filename.endsWith(".csv") ? filename : `${filename}.csv`, ); } // Recharts renders an , but its colors/fonts come from CSS vars + Tailwind // classes that don't travel with a serialized SVG. Inline the computed paint // properties onto a clone so the rasterized PNG matches what's on screen. const PAINT_PROPS = [ "fill", "stroke", "stroke-width", "stroke-dasharray", "color", "font-family", "font-size", "font-weight", "opacity", "text-anchor", ] as const; function inlineStyles(src: Element, dst: Element): void { const cs = getComputedStyle(src as HTMLElement); let style = ""; for (const p of PAINT_PROPS) { const v = cs.getPropertyValue(p); if (v) style += `${p}:${v};`; } dst.setAttribute("style", style); const srcChildren = src.children; const dstChildren = dst.children; for (let i = 0; i < srcChildren.length; i++) { inlineStyles(srcChildren[i], dstChildren[i]); } } async function svgToPngBlob( svg: SVGSVGElement, scale = 2, background = "#ffffff", ): Promise { const rect = svg.getBoundingClientRect(); const width = rect.width || svg.clientWidth || 640; const height = rect.height || svg.clientHeight || 360; const clone = svg.cloneNode(true) as SVGSVGElement; inlineStyles(svg, clone); clone.setAttribute("width", String(width)); clone.setAttribute("height", String(height)); clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); const svgString = new XMLSerializer().serializeToString(clone); const svgUrl = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svgString); const img = new Image(); await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = () => reject(new Error("Failed to rasterize chart SVG")); img.src = svgUrl; }); const canvas = document.createElement("canvas"); canvas.width = Math.ceil(width * scale); canvas.height = Math.ceil(height * scale); const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas 2D context unavailable"); ctx.fillStyle = background; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); return await new Promise((resolve, reject) => { canvas.toBlob( (blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), "image/png", ); }); } // Find the recharts inside a chart card container. export function findChartSvg(container: HTMLElement | null): SVGSVGElement | null { return (container?.querySelector(".recharts-surface") ?? container?.querySelector("svg")) as SVGSVGElement | null; } export async function exportPng( container: HTMLElement | null, filename: string, ): Promise { const svg = findChartSvg(container); if (!svg) throw new Error("No chart to export"); const blob = await svgToPngBlob(svg); downloadBlob(blob, filename.endsWith(".png") ? filename : `${filename}.png`); } export async function copyPngToClipboard( container: HTMLElement | null, ): Promise { const svg = findChartSvg(container); if (!svg) throw new Error("No chart to copy"); const blob = await svgToPngBlob(svg); const ClipboardItemCtor = ( globalThis as { ClipboardItem?: typeof ClipboardItem } ).ClipboardItem; if (!ClipboardItemCtor || !navigator.clipboard?.write) { throw new Error("Clipboard image copy not supported in this browser"); } await navigator.clipboard.write([ new ClipboardItemCtor({ "image/png": blob }), ]); }