better download

This commit is contained in:
terraputix
2026-02-15 18:51:44 +01:00
parent e12add9ac3
commit adbbb462bf
5 changed files with 469 additions and 345 deletions
+215 -28
View File
@@ -2,10 +2,9 @@
* ECharts Download Utilities
*
* Provides programmatic chart export functionality for downloading
* charts as PNG or SVG images. These utilities wrap ECharts' built-in
* export capabilities with a convenient API and sensible defaults.
* charts as PNG or SVG images. Supports stitching multiple chart
* instances into a single combined meteogram image.
*/
import type * as echarts from 'echarts';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -49,7 +48,6 @@ export function downloadChart(chart: echarts.ECharts, options: DownloadOptions =
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
// Use ECharts' getDataURL for PNG, getConnectedDataURL for SVG
const dataUrl = chart.getDataURL({
type: format === 'svg' ? 'svg' : 'png',
pixelRatio: format === 'png' ? pixelRatio : 1,
@@ -61,25 +59,47 @@ export function downloadChart(chart: echarts.ECharts, options: DownloadOptions =
}
/**
* Downloads all provided ECharts instances as separate image files.
* Each file is named with an incrementing suffix (e.g. chart-1.png, chart-2.png).
* Downloads multiple ECharts instances stitched into a single combined
* meteogram image. Charts are stacked vertically in the order provided.
*
* @param charts - Array of ECharts instances to export
* @param options - Download configuration options (fileName is used as prefix)
* For a single chart, delegates to `downloadChart`.
*
* @param charts - Array of ECharts instances to combine
* @param options - Download configuration options
*/
export function downloadAllCharts(
charts: echarts.ECharts[],
options: DownloadOptions = {}
): void {
const { fileName = DEFAULT_FILE_NAME, ...rest } = options;
export function downloadMeteogram(charts: echarts.ECharts[], options: DownloadOptions = {}): void {
const validCharts = charts.filter((c) => c && !c.isDisposed());
if (validCharts.length === 0) return;
charts.forEach((chart, index) => {
if (chart && !chart.isDisposed()) {
downloadChart(chart, {
...rest,
fileName: charts.length === 1 ? fileName : `${fileName}-${index + 1}`
});
}
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
});
}
@@ -91,10 +111,7 @@ export function downloadAllCharts(
* @param options - Export configuration options
* @returns A base64-encoded data URL string
*/
export function getChartDataUrl(
chart: echarts.ECharts,
options: DownloadOptions = {}
): string {
export function getChartDataUrl(chart: echarts.ECharts, options: DownloadOptions = {}): string {
const {
format = 'png',
pixelRatio = DEFAULT_PIXEL_RATIO,
@@ -112,22 +129,192 @@ export function getChartDataUrl(
});
}
// ─── 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.
* Triggers a browser file download from a data URL or object URL.
* Creates a temporary anchor element, clicks it, and removes it.
*/
function triggerDownload(dataUrl: string, fileName: string): void {
function triggerDownload(url: string, fileName: string): void {
const link = document.createElement('a');
link.href = dataUrl;
link.href = url;
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
// Clean up the DOM after a brief delay to ensure the download starts
requestAnimationFrame(() => {
document.body.removeChild(link);
});