remove echarts, use canvas

This commit is contained in:
Vincent van der Wal
2026-07-19 15:25:04 +02:00
parent 6d81af8df5
commit e031716ce6
37 changed files with 1483 additions and 2510 deletions
+80 -66
View File
@@ -3,7 +3,6 @@
Provides a toolbar row with:
- 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
@@ -11,7 +10,7 @@
Usage:
<ChartToolbar
charts={chartInstances}
charts={chartComponents}
fileName="model-comparison"
>
{#snippet controls()}
@@ -19,22 +18,23 @@
{/snippet}
</ChartToolbar>
-->
<script lang="ts">
import { downloadMeteogram } from '$lib/utils/echarts/download';
<script module lang="ts">
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
export interface DownloadableChart {
getPngDataUrl(): string | null;
}
</script>
import type { ExportFormat } from '$lib/utils/echarts/download';
import type * as echarts from 'echarts';
<script lang="ts">
import type { Snippet } from 'svelte';
// ─── Props ──────────────────────────────────────────────────────────────────
interface Props {
/** Array of ECharts instances available for download */
charts?: echarts.ECharts[];
/** Chart components available for download (undefined entries are skipped) */
charts?: Array<DownloadableChart | undefined | null>;
/** Base file name for downloaded images (without extension) */
fileName?: string;
/** Pixel ratio for PNG exports (default: 2) */
pixelRatio?: number;
/** Optional CSS class for the outer container */
class?: string;
/** Slot for additional controls (switches, checkboxes, etc.) */
@@ -43,33 +43,88 @@
let {
charts = [],
fileName = 'open-meteo-chart',
pixelRatio = 2,
fileName = 'ombrella-chart',
class: className = '',
controls
}: Props = $props();
// ─── State ──────────────────────────────────────────────────────────────────
let downloadingFormat: ExportFormat | null = $state(null);
let downloading = $state(false);
// ─── Computed ───────────────────────────────────────────────────────────────
let hasCharts = $derived(charts.length > 0);
let hasCharts = $derived(charts.some((chart) => chart != null));
// ─── Handlers ───────────────────────────────────────────────────────────────
// ─── Download ───────────────────────────────────────────────────────────────
async function handleDownload(format: ExportFormat): Promise<void> {
if (!hasCharts || downloadingFormat) return;
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(img);
img.src = src;
});
}
downloadingFormat = format;
/** Resolves the page background so exports match the current theme. */
function exportBackground(): string {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
return bg || '#ffffff';
}
function triggerDownload(url: string, name: string): void {
const link = document.createElement('a');
link.href = url;
link.download = name;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
requestAnimationFrame(() => {
document.body.removeChild(link);
});
}
async function handleDownload(): Promise<void> {
if (!hasCharts || downloading) return;
downloading = true;
try {
await new Promise((resolve) => setTimeout(resolve, 50));
downloadMeteogram(charts, { fileName, format, pixelRatio });
const dataUrls = charts
.filter((chart): chart is DownloadableChart => chart != null)
.map((chart) => chart.getPngDataUrl())
.filter((url): url is string => url !== null);
if (dataUrls.length === 0) return;
const images = (await Promise.all(dataUrls.map(loadImage))).filter(
(img) => img.naturalWidth > 0
);
if (images.length === 0) return;
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
const totalHeight = images.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;
ctx.fillStyle = exportBackground();
ctx.fillRect(0, 0, maxWidth, totalHeight);
let y = 0;
for (const img of images) {
ctx.drawImage(img, 0, y);
y += img.naturalHeight;
}
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
} finally {
setTimeout(() => {
downloadingFormat = null;
downloading = false;
}, 500);
}
}
@@ -85,17 +140,16 @@
{/if}
</div>
<!-- Right side: Download buttons -->
<!-- Right side: Download button -->
<div class="flex flex-wrap items-center gap-2">
<!-- Download as PNG -->
<button
type="button"
class="toolbar-btn"
disabled={!hasCharts || downloadingFormat !== null}
onclick={() => handleDownload('png')}
disabled={!hasCharts || downloading}
onclick={handleDownload}
title="Download meteogram as PNG image"
>
{#if downloadingFormat === 'png'}
{#if downloading}
<svg
class="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
@@ -126,46 +180,6 @@
{/if}
<span>PNG</span>
</button>
<!-- Download as SVG -->
<button
type="button"
class="toolbar-btn"
disabled={!hasCharts || downloadingFormat !== null}
onclick={() => handleDownload('svg')}
title="Download meteogram as SVG vector image"
>
{#if downloadingFormat === 'svg'}
<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>SVG</span>
</button>
</div>
</div>