229 lines
6.3 KiB
Svelte
229 lines
6.3 KiB
Svelte
<!--
|
|
ChartToolbar.svelte — Chart action bar with download and display controls
|
|
|
|
Provides a toolbar row with:
|
|
- Download full meteogram as PNG 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
|
|
charts={chartComponents}
|
|
fileName="model-comparison"
|
|
>
|
|
{#snippet controls()}
|
|
<Switch bind:checked={showLegend} />
|
|
{/snippet}
|
|
</ChartToolbar>
|
|
-->
|
|
<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>
|
|
|
|
<script lang="ts">
|
|
import type { Snippet } from 'svelte';
|
|
|
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
|
|
interface Props {
|
|
/** Chart components available for download (undefined entries are skipped) */
|
|
charts?: Array<DownloadableChart | undefined | null>;
|
|
/** Base file name for downloaded images (without extension) */
|
|
fileName?: string;
|
|
/** Optional CSS class for the outer container */
|
|
class?: string;
|
|
/** Slot for additional controls (switches, checkboxes, etc.) */
|
|
controls?: Snippet;
|
|
}
|
|
|
|
let {
|
|
charts = [],
|
|
fileName = 'drizzli-chart',
|
|
class: className = '',
|
|
controls
|
|
}: Props = $props();
|
|
|
|
// ─── State ──────────────────────────────────────────────────────────────────
|
|
|
|
let downloading = $state(false);
|
|
|
|
// ─── Computed ───────────────────────────────────────────────────────────────
|
|
|
|
let hasCharts = $derived(charts.some((chart) => chart != null));
|
|
|
|
// ─── Download ───────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
/** 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 {
|
|
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(() => {
|
|
downloading = false;
|
|
}, 500);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<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}
|
|
{@render controls()}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Right side: Download button -->
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="toolbar-btn"
|
|
disabled={!hasCharts || downloading}
|
|
onclick={handleDownload}
|
|
title="Download meteogram as PNG image"
|
|
>
|
|
{#if downloading}
|
|
<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>PNG</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.toolbar-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.375rem 0.75rem;
|
|
font-size: 0.8125rem;
|
|
font-weight: 500;
|
|
line-height: 1.25rem;
|
|
color: hsl(var(--muted-foreground));
|
|
background: hsl(var(--muted) / 0.5);
|
|
border: 1px solid hsl(var(--border));
|
|
border-radius: var(--radius, 0.375rem);
|
|
cursor: pointer;
|
|
transition:
|
|
color 150ms ease,
|
|
background-color 150ms ease,
|
|
border-color 150ms ease;
|
|
white-space: nowrap;
|
|
user-select: none;
|
|
}
|
|
|
|
.toolbar-btn:hover:not(:disabled) {
|
|
color: hsl(var(--foreground));
|
|
background: hsl(var(--muted));
|
|
border-color: hsl(var(--foreground) / 0.2);
|
|
}
|
|
|
|
.toolbar-btn:active:not(:disabled) {
|
|
background: hsl(var(--muted) / 0.8);
|
|
transform: translateY(0.5px);
|
|
}
|
|
|
|
.toolbar-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.toolbar-btn:focus-visible {
|
|
outline: 2px solid hsl(var(--ring));
|
|
outline-offset: 2px;
|
|
}
|
|
</style>
|