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
+16 -80
View File
@@ -2,10 +2,12 @@
ChartToolbar.svelte — Chart action bar with download and display controls ChartToolbar.svelte — Chart action bar with download and display controls
Provides a toolbar row with: Provides a toolbar row with:
- Download as PNG button - Download full meteogram as PNG button
- Download as SVG button - Download full meteogram as SVG button
- Download all charts button (when multiple charts exist) - Slot for additional custom controls (e.g. legend toggle)
- Slot for additional custom controls (e.g. legend toggle, average toggle)
When multiple charts are provided, they are stitched into a single
combined image on download.
Usage: Usage:
<ChartToolbar <ChartToolbar
@@ -18,10 +20,11 @@
</ChartToolbar> </ChartToolbar>
--> -->
<script lang="ts"> <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 * as echarts from 'echarts';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { downloadChart, downloadAllCharts } from '$lib/utils/echarts/download';
import type { ExportFormat } from '$lib/utils/echarts/download';
// ─── Props ────────────────────────────────────────────────────────────────── // ─── Props ──────────────────────────────────────────────────────────────────
@@ -32,8 +35,6 @@
fileName?: string; fileName?: string;
/** Pixel ratio for PNG exports (default: 2) */ /** Pixel ratio for PNG exports (default: 2) */
pixelRatio?: number; pixelRatio?: number;
/** Whether to show the "Download All" button when multiple charts exist */
showDownloadAll?: boolean;
/** Optional CSS class for the outer container */ /** Optional CSS class for the outer container */
class?: string; class?: string;
/** Slot for additional controls (switches, checkboxes, etc.) */ /** Slot for additional controls (switches, checkboxes, etc.) */
@@ -44,19 +45,17 @@
charts = [], charts = [],
fileName = 'open-meteo-chart', fileName = 'open-meteo-chart',
pixelRatio = 2, pixelRatio = 2,
showDownloadAll = true,
class: className = '', class: className = '',
controls controls
}: Props = $props(); }: Props = $props();
// ─── State ────────────────────────────────────────────────────────────────── // ─── State ──────────────────────────────────────────────────────────────────
let downloadingFormat: ExportFormat | 'all' | null = $state(null); let downloadingFormat: ExportFormat | null = $state(null);
// ─── Computed ─────────────────────────────────────────────────────────────── // ─── Computed ───────────────────────────────────────────────────────────────
let hasCharts = $derived(charts.length > 0); let hasCharts = $derived(charts.length > 0);
let hasMultipleCharts = $derived(charts.length > 1);
// ─── Handlers ─────────────────────────────────────────────────────────────── // ─── Handlers ───────────────────────────────────────────────────────────────
@@ -66,30 +65,8 @@
downloadingFormat = format; downloadingFormat = format;
try { try {
// Small delay to let the UI update to show the loading state
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
downloadMeteogram(charts, { fileName, format, pixelRatio });
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 });
} finally { } finally {
setTimeout(() => { setTimeout(() => {
downloadingFormat = null; downloadingFormat = null;
@@ -98,7 +75,9 @@
} }
</script> </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 --> <!-- Left side: Custom controls slot -->
<div class="flex flex-wrap items-center gap-4 md:gap-6"> <div class="flex flex-wrap items-center gap-4 md:gap-6">
{#if controls} {#if controls}
@@ -114,7 +93,7 @@
class="toolbar-btn" class="toolbar-btn"
disabled={!hasCharts || downloadingFormat !== null} disabled={!hasCharts || downloadingFormat !== null}
onclick={() => handleDownload('png')} onclick={() => handleDownload('png')}
title="Download chart as PNG image" title="Download meteogram as PNG image"
> >
{#if downloadingFormat === 'png'} {#if downloadingFormat === 'png'}
<svg <svg
@@ -154,7 +133,7 @@
class="toolbar-btn" class="toolbar-btn"
disabled={!hasCharts || downloadingFormat !== null} disabled={!hasCharts || downloadingFormat !== null}
onclick={() => handleDownload('svg')} onclick={() => handleDownload('svg')}
title="Download chart as SVG vector image" title="Download meteogram as SVG vector image"
> >
{#if downloadingFormat === 'svg'} {#if downloadingFormat === 'svg'}
<svg <svg
@@ -187,49 +166,6 @@
{/if} {/if}
<span>SVG</span> <span>SVG</span>
</button> </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>
</div> </div>
+215 -28
View File
@@ -2,10 +2,9 @@
* ECharts Download Utilities * ECharts Download Utilities
* *
* Provides programmatic chart export functionality for downloading * Provides programmatic chart export functionality for downloading
* charts as PNG or SVG images. These utilities wrap ECharts' built-in * charts as PNG or SVG images. Supports stitching multiple chart
* export capabilities with a convenient API and sensible defaults. * instances into a single combined meteogram image.
*/ */
import type * as echarts from 'echarts'; import type * as echarts from 'echarts';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -49,7 +48,6 @@ export function downloadChart(chart: echarts.ECharts, options: DownloadOptions =
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
// Use ECharts' getDataURL for PNG, getConnectedDataURL for SVG
const dataUrl = chart.getDataURL({ const dataUrl = chart.getDataURL({
type: format === 'svg' ? 'svg' : 'png', type: format === 'svg' ? 'svg' : 'png',
pixelRatio: format === 'png' ? pixelRatio : 1, 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. * Downloads multiple ECharts instances stitched into a single combined
* Each file is named with an incrementing suffix (e.g. chart-1.png, chart-2.png). * meteogram image. Charts are stacked vertically in the order provided.
* *
* @param charts - Array of ECharts instances to export * For a single chart, delegates to `downloadChart`.
* @param options - Download configuration options (fileName is used as prefix) *
* @param charts - Array of ECharts instances to combine
* @param options - Download configuration options
*/ */
export function downloadAllCharts( export function downloadMeteogram(charts: echarts.ECharts[], options: DownloadOptions = {}): void {
charts: echarts.ECharts[], const validCharts = charts.filter((c) => c && !c.isDisposed());
options: DownloadOptions = {} if (validCharts.length === 0) return;
): void {
const { fileName = DEFAULT_FILE_NAME, ...rest } = options;
charts.forEach((chart, index) => { if (validCharts.length === 1) {
if (chart && !chart.isDisposed()) { downloadChart(validCharts[0], options);
downloadChart(chart, { return;
...rest, }
fileName: charts.length === 1 ? fileName : `${fileName}-${index + 1}`
}); 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 * @param options - Export configuration options
* @returns A base64-encoded data URL string * @returns A base64-encoded data URL string
*/ */
export function getChartDataUrl( export function getChartDataUrl(chart: echarts.ECharts, options: DownloadOptions = {}): string {
chart: echarts.ECharts,
options: DownloadOptions = {}
): string {
const { const {
format = 'png', format = 'png',
pixelRatio = DEFAULT_PIXEL_RATIO, 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 ──────────────────────────────────────────────────────── // ─── 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. * 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'); const link = document.createElement('a');
link.href = dataUrl; link.href = url;
link.download = fileName; link.download = fileName;
link.style.display = 'none'; link.style.display = 'none';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
// Clean up the DOM after a brief delay to ensure the download starts
requestAnimationFrame(() => { requestAnimationFrame(() => {
document.body.removeChild(link); document.body.removeChild(link);
}); });
+1 -1
View File
@@ -68,5 +68,5 @@ export type {
} from './series'; } from './series';
// Download: export charts as PNG or SVG // 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'; export type { ExportFormat, DownloadOptions } from './download';
+119 -117
View File
@@ -27,7 +27,11 @@
import type * as echarts from 'echarts'; import type * as echarts from 'echarts';
// ─── State ────────────────────────────────────────────────────────────────── // ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: EChart[] = $state([]); let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]); let chartInstances: echarts.ECharts[] = $state([]);
@@ -35,12 +39,8 @@
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
let showLegend = $state(false);
let averageOnly = $state(false);
const location = get(storedLocation); const location = get(storedLocation);
// Local component state for chart configuration
let params = $state({ let params = $state({
latitude: [52.52], latitude: [52.52],
longitude: [13.41], longitude: [13.41],
@@ -49,6 +49,18 @@
models: ['gfs_seamless'] models: ['gfs_seamless']
}); });
// ─── Cached API Response ────────────────────────────────────────────────────
interface FetchedData {
hourly: Record<string, unknown>;
hourly_units: Record<string, string>;
utc_offset_seconds: number;
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
timestamps: number[];
}
let fetchedData: FetchedData | null = $state(null);
// ─── Lifecycle ────────────────────────────────────────────────────────────── // ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => { onMount(() => {
@@ -67,32 +79,31 @@
chartInstances = [...chartInstances, chart]; chartInstances = [...chartInstances, chart];
} }
// ─── Data Loading & Chart Building ────────────────────────────────────────── // ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => { $effect(() => {
const loadData = async () => { const hourlyVars = params.hourly;
if (!mounted) return; const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loadData = async () => {
loading = true; loading = true;
chartInstances = []; chartInstances = [];
chartComponents = []; chartComponents = [];
// Fetch sunrise/sunset from the standard forecast API const [dataDaily, dataReq] = await Promise.all([
const dataDaily = await fetch( fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14` `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
); ),
const wd = await dataDaily.json(); fetch(
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&forecast_days=14`
)
]);
// Fetch ensemble data const [wd, data] = await Promise.all([dataDaily.json(), dataReq.json()]);
const dataReq = await fetch(
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&forecast_days=14`
);
const data = await dataReq.json();
// ─── Compute daylight bands ───────────────────────────────────── let markAreas: FetchedData['markAreas'] = [];
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
[];
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) { if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
markAreas = buildDaylightMarkAreas( markAreas = buildDaylightMarkAreas(
@@ -102,90 +113,99 @@
); );
} }
// ─── Build chart options for each variable ──────────────────────
const colors = getThemeColors();
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
const variableCount = params.hourly?.length || 0;
const newOptions: Array<Record<string, unknown>> = [];
for (let vi = 0; vi < variableCount; vi++) { fetchedData = {
const variable = params.hourly![vi]; hourly: data.hourly,
const unit = findUnit(data.hourly_units, data.hourly, variable); hourly_units: data.hourly_units,
const timeLength = data.hourly.time.length; utc_offset_seconds: data.utc_offset_seconds,
markAreas,
timestamps
};
// ─── Calculate average and spread ───────────────────────────
const { average } = calculateAverage(data.hourly, variable, timeLength);
const { minValues, maxValues } = calculateSpread(data.hourly, variable, timeLength);
// ─── Build series ───────────────────────────────────────────
const series: Array<Record<string, unknown>> = [];
// Ensemble spread (min/max area)
const spreadData: Array<[number, number, number]> = minValues.map(
(min, index) =>
[timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number]
);
series.push(...buildSpreadSeries({ variable, spreadData }));
// Average line
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
// Current time marker
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
// Daylight bands
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
// ─── Compose final option ───────────────────────────────────
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
const option = composeChartOption({
title: isFirst
? {
text: 'Model Spread',
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit },
legend: {
show: showLegend,
data: [variable + '_average']
},
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend
},
yAxis: { unit },
series,
toolbox: {
saveAsImage: true,
fileName: `14-day-forecast-${variable}`,
format: 'png'
},
showCredit: isLast,
colors
});
newOptions.push(option);
}
chartOptions = newOptions;
loading = false; loading = false;
}; };
loadData(); loadData();
}); });
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
$effect(() => {
if (!fetchedData) return;
const {
hourly: hourlyData,
hourly_units,
utc_offset_seconds,
markAreas,
timestamps
} = fetchedData;
const _showLegend = showLegend;
const colors = getThemeColors();
const variableCount = params.hourly?.length || 0;
const timeLength = (hourlyData.time as number[]).length;
const newOptions: Array<Record<string, unknown>> = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = params.hourly![vi];
const unit = findUnit(hourly_units, hourlyData, variable);
const { average } = calculateAverage(hourlyData, variable, timeLength);
const { minValues, maxValues } = calculateSpread(hourlyData, variable, timeLength);
const series: Array<Record<string, unknown>> = [];
const spreadData: Array<[number, number, number]> = minValues.map(
(min, index) =>
[timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number]
);
series.push(...buildSpreadSeries({ variable, spreadData }));
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
const option = composeChartOption({
title: isFirst
? {
text: 'Model Spread',
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit },
legend: {
show: _showLegend,
data: [variable + '_average']
},
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend: _showLegend
},
yAxis: { unit },
series,
toolbox: false,
showCredit: isLast,
colors
});
newOptions.push(option);
}
chartOptions = newOptions;
});
</script> </script>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
@@ -211,27 +231,9 @@
<ChartToolbar charts={chartInstances} fileName="14-day-forecast"> <ChartToolbar charts={chartInstances} fileName="14-day-forecast">
{#snippet controls()} {#snippet controls()}
<div class="flex gap-2"> <div class="flex gap-2">
<Switch <Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
id="show_legend"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label> <Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
</div> </div>
<div class="flex gap-2">
<Switch
id="average_only"
name="Average only"
bind:checked={averageOnly}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
</div>
{/snippet} {/snippet}
</ChartToolbar> </ChartToolbar>
</div> </div>
+118 -119
View File
@@ -29,10 +29,13 @@
import type * as echarts from 'echarts'; import type * as echarts from 'echarts';
// Wrap models in array to match template expectation of nested arrays like hourly
const models = [modelsFlat]; const models = [modelsFlat];
// ─── State ────────────────────────────────────────────────────────────────── // ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: EChart[] = $state([]); let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]); let chartInstances: echarts.ECharts[] = $state([]);
@@ -40,9 +43,6 @@
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
let showLegend = $state(false);
let averageOnly = $state(false);
const location = get(storedLocation); const location = get(storedLocation);
let params = $state({ let params = $state({
@@ -59,6 +59,18 @@
] ]
}); });
// ─── Cached API Response ────────────────────────────────────────────────────
interface FetchedData {
hourly: Record<string, unknown>;
hourly_units: Record<string, string>;
utc_offset_seconds: number;
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
timestamps: number[];
}
let fetchedData: FetchedData | null = $state(null);
// ─── Lifecycle ────────────────────────────────────────────────────────────── // ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => { onMount(() => {
@@ -77,28 +89,27 @@
chartInstances = [...chartInstances, chart]; chartInstances = [...chartInstances, chart];
} }
// ─── Data Loading & Chart Building ────────────────────────────────────────── // ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => { $effect(() => {
const loadData = async () => { const hourlyVars = params.hourly;
if (!mounted) return; const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loadData = async () => {
loading = true; loading = true;
chartInstances = []; chartInstances = [];
chartComponents = []; chartComponents = [];
const dataReq = await fetch( const dataReq = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise` `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&daily=sunset,sunrise`
); );
const data = await dataReq.json(); const data = await dataReq.json();
// ─── Compute daylight bands ───────────────────────────────────── let markAreas: FetchedData['markAreas'] = [];
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
[];
if ('daily' in data) { if ('daily' in data) {
// Find the first model-suffixed key for sunrise/sunset
let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_'); let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
dailyFirstModelKey.shift(); dailyFirstModelKey.shift();
dailyFirstModelKey = dailyFirstModelKey.join('_'); dailyFirstModelKey = dailyFirstModelKey.join('_');
@@ -115,96 +126,104 @@
} }
} }
// ─── Build chart options for each variable ──────────────────────
const colors = getThemeColors();
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
const variableCount = params.hourly?.length || 0;
const newOptions: Array<Record<string, unknown>> = [];
for (let vi = 0; vi < variableCount; vi++) { fetchedData = {
const variable = params.hourly![vi]; hourly: data.hourly,
const unit = findUnit(data.hourly_units, data.hourly, variable); hourly_units: data.hourly_units,
const timeLength = data.hourly.time.length; utc_offset_seconds: data.utc_offset_seconds,
markAreas,
timestamps
};
// ─── Build individual model series ───────────────────────────
const series: Array<Record<string, unknown>> = [];
if (!averageOnly) {
for (const [model, values] of Object.entries(data.hourly)) {
if (model === 'time') continue;
if (!model.startsWith(variable)) continue;
const seriesData = (values as (number | null)[]).map(
(val, idx) => [timestamps[idx], val] as [number, number | null]
);
series.push(
buildModelSeries({
name: model,
data: seriesData,
unit
})
);
}
}
// ─── Average series ─────────────────────────────────────────
const { average } = calculateAverage(data.hourly, variable, timeLength);
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
// ─── Annotation series ───────────────────────────────────────
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
// ─── Compose final option ───────────────────────────────────
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
const option = composeChartOption({
title: isFirst
? {
text: 'Model Compare',
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit },
legend: { show: showLegend },
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend
},
yAxis: { unit },
series,
toolbox: {
saveAsImage: true,
fileName: `model-compare-${variable}`,
format: 'png'
},
showCredit: isLast,
colors
});
newOptions.push(option);
}
chartOptions = newOptions;
loading = false; loading = false;
}; };
loadData(); loadData();
}); });
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
$effect(() => {
if (!fetchedData) return;
const {
hourly: hourlyData,
hourly_units,
utc_offset_seconds,
markAreas,
timestamps
} = fetchedData;
const _showLegend = showLegend;
const colors = getThemeColors();
const variableCount = params.hourly?.length || 0;
const timeLength = (hourlyData.time as number[]).length;
const newOptions: Array<Record<string, unknown>> = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = params.hourly![vi];
const unit = findUnit(hourly_units, hourlyData, variable);
const series: Array<Record<string, unknown>> = [];
for (const [model, values] of Object.entries(hourlyData)) {
if (model === 'time') continue;
if (!model.startsWith(variable)) continue;
const seriesData = (values as (number | null)[]).map(
(val, idx) => [timestamps[idx], val] as [number, number | null]
);
series.push(
buildModelSeries({
name: model,
data: seriesData,
unit
})
);
}
const { average } = calculateAverage(hourlyData, variable, timeLength);
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
const option = composeChartOption({
title: isFirst
? {
text: 'Model Compare',
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit },
legend: { show: _showLegend },
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend: _showLegend
},
yAxis: { unit },
series,
toolbox: false,
showCredit: isLast,
colors
});
newOptions.push(option);
}
chartOptions = newOptions;
});
</script> </script>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
@@ -230,27 +249,9 @@
<ChartToolbar charts={chartInstances} fileName="model-comparison"> <ChartToolbar charts={chartInstances} fileName="model-comparison">
{#snippet controls()} {#snippet controls()}
<div class="flex gap-2"> <div class="flex gap-2">
<Switch <Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
id="show_legend"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label> <Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
</div> </div>
<div class="flex gap-2">
<Switch
id="average_only"
name="Average only"
bind:checked={averageOnly}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
</div>
{/snippet} {/snippet}
</ChartToolbar> </ChartToolbar>
</div> </div>
@@ -289,8 +290,7 @@
return item !== value; return item !== value;
}); });
} else if (params.models) { } else if (params.models) {
params.models.push(value); params.models = [...params.models, value];
params.models = params.models;
} }
}} }}
/> />
@@ -344,8 +344,7 @@
return item !== value; return item !== value;
}); });
} else if (params.hourly) { } else if (params.hourly) {
params.hourly.push(value); params.hourly = [...params.hourly, value];
params.hourly = params.hourly;
} }
}} }}
/> />