better download
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
ChartToolbar.svelte — Chart action bar with download and display controls
|
||||
|
||||
Provides a toolbar row with:
|
||||
- Download as PNG button
|
||||
- Download as SVG button
|
||||
- Download all charts button (when multiple charts exist)
|
||||
- Slot for additional custom controls (e.g. legend toggle, average toggle)
|
||||
- Download full meteogram as PNG button
|
||||
- Download full meteogram as SVG button
|
||||
- Slot for additional custom controls (e.g. legend toggle)
|
||||
|
||||
When multiple charts are provided, they are stitched into a single
|
||||
combined image on download.
|
||||
|
||||
Usage:
|
||||
<ChartToolbar
|
||||
@@ -18,10 +20,11 @@
|
||||
</ChartToolbar>
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { downloadMeteogram } from '$lib/utils/echarts/download';
|
||||
|
||||
import type { ExportFormat } from '$lib/utils/echarts/download';
|
||||
import type * as echarts from 'echarts';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { downloadChart, downloadAllCharts } from '$lib/utils/echarts/download';
|
||||
import type { ExportFormat } from '$lib/utils/echarts/download';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,8 +35,6 @@
|
||||
fileName?: string;
|
||||
/** Pixel ratio for PNG exports (default: 2) */
|
||||
pixelRatio?: number;
|
||||
/** Whether to show the "Download All" button when multiple charts exist */
|
||||
showDownloadAll?: boolean;
|
||||
/** Optional CSS class for the outer container */
|
||||
class?: string;
|
||||
/** Slot for additional controls (switches, checkboxes, etc.) */
|
||||
@@ -44,19 +45,17 @@
|
||||
charts = [],
|
||||
fileName = 'open-meteo-chart',
|
||||
pixelRatio = 2,
|
||||
showDownloadAll = true,
|
||||
class: className = '',
|
||||
controls
|
||||
}: Props = $props();
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let downloadingFormat: ExportFormat | 'all' | null = $state(null);
|
||||
let downloadingFormat: ExportFormat | null = $state(null);
|
||||
|
||||
// ─── Computed ───────────────────────────────────────────────────────────────
|
||||
|
||||
let hasCharts = $derived(charts.length > 0);
|
||||
let hasMultipleCharts = $derived(charts.length > 1);
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -66,30 +65,8 @@
|
||||
downloadingFormat = format;
|
||||
|
||||
try {
|
||||
// Small delay to let the UI update to show the loading state
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
if (charts.length === 1) {
|
||||
downloadChart(charts[0], { fileName, format, pixelRatio });
|
||||
} else {
|
||||
downloadAllCharts(charts, { fileName, format, pixelRatio });
|
||||
}
|
||||
} finally {
|
||||
// Reset state after a brief moment
|
||||
setTimeout(() => {
|
||||
downloadingFormat = null;
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadAll(format: ExportFormat = 'png'): Promise<void> {
|
||||
if (!hasCharts || downloadingFormat) return;
|
||||
|
||||
downloadingFormat = 'all';
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
downloadAllCharts(charts, { fileName, format, pixelRatio });
|
||||
downloadMeteogram(charts, { fileName, format, pixelRatio });
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
downloadingFormat = null;
|
||||
@@ -98,7 +75,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chart-toolbar flex flex-col items-center gap-4 md:flex-row md:justify-between {className}">
|
||||
<div
|
||||
class="chart-toolbar flex flex-col items-center gap-4 md:flex-row md:justify-between {className}"
|
||||
>
|
||||
<!-- Left side: Custom controls slot -->
|
||||
<div class="flex flex-wrap items-center gap-4 md:gap-6">
|
||||
{#if controls}
|
||||
@@ -114,7 +93,7 @@
|
||||
class="toolbar-btn"
|
||||
disabled={!hasCharts || downloadingFormat !== null}
|
||||
onclick={() => handleDownload('png')}
|
||||
title="Download chart as PNG image"
|
||||
title="Download meteogram as PNG image"
|
||||
>
|
||||
{#if downloadingFormat === 'png'}
|
||||
<svg
|
||||
@@ -154,7 +133,7 @@
|
||||
class="toolbar-btn"
|
||||
disabled={!hasCharts || downloadingFormat !== null}
|
||||
onclick={() => handleDownload('svg')}
|
||||
title="Download chart as SVG vector image"
|
||||
title="Download meteogram as SVG vector image"
|
||||
>
|
||||
{#if downloadingFormat === 'svg'}
|
||||
<svg
|
||||
@@ -187,49 +166,6 @@
|
||||
{/if}
|
||||
<span>SVG</span>
|
||||
</button>
|
||||
|
||||
<!-- Download All (only shown when there are multiple charts) -->
|
||||
{#if showDownloadAll && hasMultipleCharts}
|
||||
<div class="mx-1 hidden h-5 w-px bg-border md:block" aria-hidden="true"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
disabled={!hasCharts || downloadingFormat !== null}
|
||||
onclick={() => handleDownloadAll('png')}
|
||||
title="Download all charts as separate PNG images"
|
||||
>
|
||||
{#if downloadingFormat === 'all'}
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span>All ({charts.length})</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -68,5 +68,5 @@ export type {
|
||||
} from './series';
|
||||
|
||||
// Download: export charts as PNG or SVG
|
||||
export { downloadChart, downloadAllCharts, getChartDataUrl } from './download';
|
||||
export { downloadChart, downloadMeteogram, getChartDataUrl } from './download';
|
||||
export type { ExportFormat, DownloadOptions } from './download';
|
||||
|
||||
Reference in New Issue
Block a user