/** * ECharts Download Utilities * * Provides programmatic chart export functionality for downloading * charts as PNG or SVG images. Supports stitching multiple chart * instances into a single combined meteogram image. */ import type * as echarts from 'echarts'; // ─── Types ─────────────────────────────────────────────────────────────────── export type ExportFormat = 'png' | 'svg'; export interface DownloadOptions { /** The file name (without extension) */ fileName?: string; /** Export format: 'png' or 'svg' */ format?: ExportFormat; /** Pixel ratio for PNG exports (default: 2 for retina quality) */ pixelRatio?: number; /** Background color (default: '#ffffff' for PNG, 'none' for SVG) */ backgroundColor?: string; /** Components to exclude from the export (e.g. ['toolbox']) */ excludeComponents?: string[]; } // ─── Defaults ──────────────────────────────────────────────────────────────── const DEFAULT_FILE_NAME = 'open-meteo-chart'; const DEFAULT_PIXEL_RATIO = 2; // ─── Download Functions ────────────────────────────────────────────────────── /** * Downloads a single ECharts instance as an image file. * * @param chart - The ECharts instance to export * @param options - Download configuration options */ export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = {}): void { const { fileName = DEFAULT_FILE_NAME, format = 'png', pixelRatio = DEFAULT_PIXEL_RATIO, backgroundColor, excludeComponents = ['toolbox'] } = options; const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); const dataUrl = chart.getDataURL({ type: format === 'svg' ? 'svg' : 'png', pixelRatio: format === 'png' ? pixelRatio : 1, backgroundColor: resolvedBg, excludeComponents }); triggerDownload(dataUrl, `${fileName}.${format}`); } /** * Downloads multiple ECharts instances stitched into a single combined * meteogram image. Charts are stacked vertically in the order provided. * * For a single chart, delegates to `downloadChart`. * * @param charts - Array of ECharts instances to combine * @param options - Download configuration options */ export function downloadMeteogram(charts: echarts.ECharts[], options: DownloadOptions = {}): void { const validCharts = charts.filter((c) => c && !c.isDisposed()); if (validCharts.length === 0) return; if (validCharts.length === 1) { downloadChart(validCharts[0], options); return; } const { fileName = DEFAULT_FILE_NAME, format = 'png', pixelRatio = DEFAULT_PIXEL_RATIO, backgroundColor, excludeComponents = ['toolbox'] } = options; const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); if (format === 'svg') { downloadMeteogramSvg(validCharts, { fileName, backgroundColor: resolvedBg, excludeComponents }); return; } downloadMeteogramPng(validCharts, { fileName, pixelRatio, backgroundColor: resolvedBg, excludeComponents }); } /** * Returns the data URL of a chart without triggering a download. * Useful for previewing or embedding chart images programmatically. * * @param chart - The ECharts instance to export * @param options - Export configuration options * @returns A base64-encoded data URL string */ export function getChartDataUrl(chart: echarts.ECharts, options: DownloadOptions = {}): string { const { format = 'png', pixelRatio = DEFAULT_PIXEL_RATIO, backgroundColor, excludeComponents = ['toolbox'] } = options; const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); return chart.getDataURL({ type: format === 'svg' ? 'svg' : 'png', pixelRatio: format === 'png' ? pixelRatio : 1, backgroundColor: resolvedBg, excludeComponents }); } // ─── Internal: PNG Meteogram ───────────────────────────────────────────────── interface PngStitchOptions { fileName: string; pixelRatio: number; backgroundColor: string; excludeComponents: string[]; } /** * Stitches multiple charts into a single PNG by rendering each chart's * data URL onto an off-screen canvas, stacked vertically. */ function downloadMeteogramPng(charts: echarts.ECharts[], opts: PngStitchOptions): void { const { fileName, pixelRatio, backgroundColor, excludeComponents } = opts; const dataUrls = charts.map((chart) => chart.getDataURL({ type: 'png', pixelRatio, backgroundColor: 'transparent', excludeComponents }) ); const images: HTMLImageElement[] = []; let loadedCount = 0; dataUrls.forEach((url, index) => { const img = new Image(); images[index] = img; img.onload = () => { loadedCount++; if (loadedCount === dataUrls.length) { composePngAndDownload(images, fileName, backgroundColor); } }; img.onerror = () => { loadedCount++; if (loadedCount === dataUrls.length) { composePngAndDownload(images, fileName, backgroundColor); } }; img.src = url; }); } function composePngAndDownload( images: HTMLImageElement[], fileName: string, backgroundColor: string ): void { const validImages = images.filter((img) => img.naturalWidth > 0); if (validImages.length === 0) return; const maxWidth = Math.max(...validImages.map((img) => img.naturalWidth)); const totalHeight = validImages.reduce((sum, img) => sum + img.naturalHeight, 0); const canvas = document.createElement('canvas'); canvas.width = maxWidth; canvas.height = totalHeight; const ctx = canvas.getContext('2d'); if (!ctx) return; if (backgroundColor && backgroundColor !== 'transparent' && backgroundColor !== 'none') { ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, maxWidth, totalHeight); } let y = 0; for (const img of validImages) { ctx.drawImage(img, 0, y); y += img.naturalHeight; } const dataUrl = canvas.toDataURL('image/png'); triggerDownload(dataUrl, `${fileName}.png`); } // ─── Internal: SVG Meteogram ───────────────────────────────────────────────── interface SvgStitchOptions { fileName: string; backgroundColor: string; excludeComponents: string[]; } /** * Stitches multiple charts into a single SVG by extracting each chart's * SVG markup and embedding them as nested groups with vertical offsets. */ function downloadMeteogramSvg(charts: echarts.ECharts[], opts: SvgStitchOptions): void { const { fileName, backgroundColor, excludeComponents } = opts; const svgStrings = charts.map((chart) => chart.getDataURL({ type: 'svg', pixelRatio: 1, backgroundColor: 'transparent', excludeComponents }) ); const parser = new DOMParser(); const fragments: { svg: SVGSVGElement; width: number; height: number }[] = []; for (const svgDataUrl of svgStrings) { const svgContent = decodeSvgDataUrl(svgDataUrl); if (!svgContent) continue; const doc = parser.parseFromString(svgContent, 'image/svg+xml'); const svg = doc.querySelector('svg'); if (!svg) continue; const width = parseFloat(svg.getAttribute('width') || '0'); const height = parseFloat(svg.getAttribute('height') || '0'); if (width > 0 && height > 0) { fragments.push({ svg, width, height }); } } if (fragments.length === 0) return; const maxWidth = Math.max(...fragments.map((f) => f.width)); const totalHeight = fragments.reduce((sum, f) => sum + f.height, 0); let combinedSvg = ``; if (backgroundColor && backgroundColor !== 'none' && backgroundColor !== 'transparent') { combinedSvg += ``; } let yOffset = 0; for (const fragment of fragments) { combinedSvg += ``; combinedSvg += fragment.svg.innerHTML; combinedSvg += ``; yOffset += fragment.height; } combinedSvg += ``; const blob = new Blob([combinedSvg], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); triggerDownload(url, `${fileName}.svg`); setTimeout(() => URL.revokeObjectURL(url), 10000); } function decodeSvgDataUrl(dataUrl: string): string | null { try { if (dataUrl.startsWith('data:image/svg+xml;charset=UTF-8,')) { return decodeURIComponent(dataUrl.slice('data:image/svg+xml;charset=UTF-8,'.length)); } if (dataUrl.startsWith('data:image/svg+xml;base64,')) { return atob(dataUrl.slice('data:image/svg+xml;base64,'.length)); } if (dataUrl.startsWith('data:image/svg+xml,')) { return decodeURIComponent(dataUrl.slice('data:image/svg+xml,'.length)); } return null; } catch { return null; } } // ─── Internal Helpers ──────────────────────────────────────────────────────── /** * Triggers a browser file download from a data URL or object URL. * Creates a temporary anchor element, clicks it, and removes it. */ function triggerDownload(url: string, fileName: string): void { const link = document.createElement('a'); link.href = url; link.download = fileName; link.style.display = 'none'; document.body.appendChild(link); link.click(); requestAnimationFrame(() => { document.body.removeChild(link); }); }