feat: canvas to echarts #5
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,28 +113,50 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Build chart options for each variable ──────────────────────
|
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
||||||
|
|
||||||
|
fetchedData = {
|
||||||
|
hourly: data.hourly,
|
||||||
|
hourly_units: data.hourly_units,
|
||||||
|
utc_offset_seconds: data.utc_offset_seconds,
|
||||||
|
markAreas,
|
||||||
|
timestamps
|
||||||
|
};
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 colors = getThemeColors();
|
||||||
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
|
||||||
const variableCount = params.hourly?.length || 0;
|
const variableCount = params.hourly?.length || 0;
|
||||||
|
const timeLength = (hourlyData.time as number[]).length;
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
const newOptions: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
for (let vi = 0; vi < variableCount; vi++) {
|
||||||
const variable = params.hourly![vi];
|
const variable = params.hourly![vi];
|
||||||
const unit = findUnit(data.hourly_units, data.hourly, variable);
|
const unit = findUnit(hourly_units, hourlyData, variable);
|
||||||
const timeLength = data.hourly.time.length;
|
|
||||||
|
|
||||||
// ─── Calculate average and spread ───────────────────────────
|
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
||||||
|
const { minValues, maxValues } = calculateSpread(hourlyData, variable, timeLength);
|
||||||
const { average } = calculateAverage(data.hourly, variable, timeLength);
|
|
||||||
const { minValues, maxValues } = calculateSpread(data.hourly, variable, timeLength);
|
|
||||||
|
|
||||||
// ─── Build series ───────────────────────────────────────────
|
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
const series: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
// Ensemble spread (min/max area)
|
|
||||||
const spreadData: Array<[number, number, number]> = minValues.map(
|
const spreadData: Array<[number, number, number]> = minValues.map(
|
||||||
(min, index) =>
|
(min, index) =>
|
||||||
[timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
[timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
||||||
@@ -131,21 +164,16 @@
|
|||||||
|
|
||||||
series.push(...buildSpreadSeries({ variable, spreadData }));
|
series.push(...buildSpreadSeries({ variable, spreadData }));
|
||||||
|
|
||||||
// Average line
|
|
||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
||||||
|
|
||||||
// Current time marker
|
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
|
||||||
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
|
|
||||||
|
|
||||||
// Daylight bands
|
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
const daylightSeries = buildDaylightSeries({ markAreas });
|
||||||
if (daylightSeries) {
|
if (daylightSeries) {
|
||||||
series.push(daylightSeries);
|
series.push(daylightSeries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Compose final option ───────────────────────────────────
|
|
||||||
|
|
||||||
const isFirst = vi === 0;
|
const isFirst = vi === 0;
|
||||||
const isLast = vi === variableCount - 1;
|
const isLast = vi === variableCount - 1;
|
||||||
|
|
||||||
@@ -158,21 +186,17 @@
|
|||||||
: null,
|
: null,
|
||||||
tooltip: { unit },
|
tooltip: { unit },
|
||||||
legend: {
|
legend: {
|
||||||
show: showLegend,
|
show: _showLegend,
|
||||||
data: [variable + '_average']
|
data: [variable + '_average']
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
hasTitle: isFirst,
|
hasTitle: isFirst,
|
||||||
hasSubtitle: isFirst,
|
hasSubtitle: isFirst,
|
||||||
showLegend
|
showLegend: _showLegend
|
||||||
},
|
},
|
||||||
yAxis: { unit },
|
yAxis: { unit },
|
||||||
series,
|
series,
|
||||||
toolbox: {
|
toolbox: false,
|
||||||
saveAsImage: true,
|
|
||||||
fileName: `14-day-forecast-${variable}`,
|
|
||||||
format: 'png'
|
|
||||||
},
|
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
colors
|
colors
|
||||||
});
|
});
|
||||||
@@ -181,10 +205,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
chartOptions = newOptions;
|
chartOptions = newOptions;
|
||||||
loading = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,24 +126,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Build chart options for each variable ──────────────────────
|
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
||||||
|
|
||||||
|
fetchedData = {
|
||||||
|
hourly: data.hourly,
|
||||||
|
hourly_units: data.hourly_units,
|
||||||
|
utc_offset_seconds: data.utc_offset_seconds,
|
||||||
|
markAreas,
|
||||||
|
timestamps
|
||||||
|
};
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 colors = getThemeColors();
|
||||||
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
|
||||||
const variableCount = params.hourly?.length || 0;
|
const variableCount = params.hourly?.length || 0;
|
||||||
|
const timeLength = (hourlyData.time as number[]).length;
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
const newOptions: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
for (let vi = 0; vi < variableCount; vi++) {
|
||||||
const variable = params.hourly![vi];
|
const variable = params.hourly![vi];
|
||||||
const unit = findUnit(data.hourly_units, data.hourly, variable);
|
const unit = findUnit(hourly_units, hourlyData, variable);
|
||||||
const timeLength = data.hourly.time.length;
|
|
||||||
|
|
||||||
// ─── Build individual model series ───────────────────────────
|
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
const series: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
if (!averageOnly) {
|
for (const [model, values] of Object.entries(hourlyData)) {
|
||||||
for (const [model, values] of Object.entries(data.hourly)) {
|
|
||||||
if (model === 'time') continue;
|
if (model === 'time') continue;
|
||||||
if (!model.startsWith(variable)) continue;
|
if (!model.startsWith(variable)) continue;
|
||||||
|
|
||||||
@@ -148,26 +183,18 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Average series ─────────────────────────────────────────
|
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
||||||
|
|
||||||
const { average } = calculateAverage(data.hourly, variable, timeLength);
|
|
||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
||||||
|
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
||||||
|
|
||||||
// ─── Annotation series ───────────────────────────────────────
|
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
|
||||||
|
|
||||||
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
|
|
||||||
|
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
const daylightSeries = buildDaylightSeries({ markAreas });
|
||||||
if (daylightSeries) {
|
if (daylightSeries) {
|
||||||
series.push(daylightSeries);
|
series.push(daylightSeries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Compose final option ───────────────────────────────────
|
|
||||||
|
|
||||||
const isFirst = vi === 0;
|
const isFirst = vi === 0;
|
||||||
const isLast = vi === variableCount - 1;
|
const isLast = vi === variableCount - 1;
|
||||||
|
|
||||||
@@ -179,19 +206,15 @@
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
tooltip: { unit },
|
tooltip: { unit },
|
||||||
legend: { show: showLegend },
|
legend: { show: _showLegend },
|
||||||
grid: {
|
grid: {
|
||||||
hasTitle: isFirst,
|
hasTitle: isFirst,
|
||||||
hasSubtitle: isFirst,
|
hasSubtitle: isFirst,
|
||||||
showLegend
|
showLegend: _showLegend
|
||||||
},
|
},
|
||||||
yAxis: { unit },
|
yAxis: { unit },
|
||||||
series,
|
series,
|
||||||
toolbox: {
|
toolbox: false,
|
||||||
saveAsImage: true,
|
|
||||||
fileName: `model-compare-${variable}`,
|
|
||||||
format: 'png'
|
|
||||||
},
|
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
colors
|
colors
|
||||||
});
|
});
|
||||||
@@ -200,10 +223,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
chartOptions = newOptions;
|
chartOptions = newOptions;
|
||||||
loading = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user