322 lines
9.6 KiB
TypeScript
322 lines
9.6 KiB
TypeScript
/**
|
|
* 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${maxWidth}" height="${totalHeight}" viewBox="0 0 ${maxWidth} ${totalHeight}">`;
|
|
|
|
if (backgroundColor && backgroundColor !== 'none' && backgroundColor !== 'transparent') {
|
|
combinedSvg += `<rect width="${maxWidth}" height="${totalHeight}" fill="${backgroundColor}"/>`;
|
|
}
|
|
|
|
let yOffset = 0;
|
|
for (const fragment of fragments) {
|
|
combinedSvg += `<g transform="translate(0,${yOffset})">`;
|
|
combinedSvg += fragment.svg.innerHTML;
|
|
combinedSvg += `</g>`;
|
|
yOffset += fragment.height;
|
|
}
|
|
|
|
combinedSvg += `</svg>`;
|
|
|
|
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);
|
|
});
|
|
}
|