feat: migrate to apache echarts (#4)

Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/ombrella#4
This commit is contained in:
2026-02-16 00:23:08 +01:00
co-authored by terraputix
parent 6b48dd6764
commit 4e3ccf1c06
23 changed files with 2373 additions and 1776 deletions
@@ -0,0 +1,118 @@
<!--
ChartContainer.svelte — Consistent chart layout wrapper
Provides a container with:
- Consistent padding and spacing
- Loading overlay with spinner
- Fade transitions
- Responsive min-height calculation
- Slot for chart content
Usage:
<ChartContainer loading={!chartsReady} chartCount={3}>
{#each charts as chart}
<EChart option={chart.option} />
{/each}
</ChartContainer>
-->
<script lang="ts">
import { fade } from 'svelte/transition';
import type { Snippet } from 'svelte';
// ─── Props ──────────────────────────────────────────────────────────────────
interface Props {
/** Whether the charts are still loading */
loading?: boolean;
/** Number of charts being rendered (used for min-height calculation) */
chartCount?: number;
/** Height per individual chart in pixels (default: 300) */
chartHeight?: number;
/** Extra vertical padding in pixels added to the total min-height (default: 2) */
extraPadding?: number;
/** Optional CSS class for the outer wrapper */
class?: string;
/** Slot content (charts go here) */
children?: Snippet;
}
let {
loading = true,
chartCount = 1,
chartHeight = 300,
extraPadding = 2,
class: className = '',
children
}: Props = $props();
// ─── Computed ───────────────────────────────────────────────────────────────
let minHeight = $derived(chartHeight * chartCount + extraPadding);
</script>
<div
class="chart-container relative {className}"
style:min-height="{minHeight}px"
>
<!-- Chart content area -->
<div
class="chart-content"
in:fade={{ duration: 300 }}
out:fade={{ duration: 300 }}
>
{#if children}
{@render children()}
{/if}
</div>
<!-- Loading overlay -->
<div
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-accent transition-opacity duration-300"
class:pointer-events-none={!loading}
class:opacity-0={!loading}
class:opacity-100={loading}
>
<div class="flex flex-col items-center gap-3">
<svg
class="lucide lucide-loader-circle animate-spin text-muted-foreground"
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="sr-only">Loading charts...</span>
</div>
</div>
</div>
<style>
.chart-container {
/* Negative horizontal margin on mobile to use full width, reset on md+ */
margin-left: -1.5rem;
margin-right: -1.5rem;
}
@media (min-width: 768px) {
.chart-container {
margin-left: 0;
margin-right: 0;
}
}
.chart-content {
width: 100%;
}
/* Smooth transition for the loading overlay */
.loading-overlay {
will-change: opacity;
}
</style>
@@ -0,0 +1,214 @@
<!--
ChartToolbar.svelte — Chart action bar with download and display controls
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
combined image on download.
Usage:
<ChartToolbar
charts={chartInstances}
fileName="model-comparison"
>
{#snippet controls()}
<Switch bind:checked={showLegend} />
{/snippet}
</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';
// ─── Props ──────────────────────────────────────────────────────────────────
interface Props {
/** Array of ECharts instances available for download */
charts?: echarts.ECharts[];
/** 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.) */
controls?: Snippet;
}
let {
charts = [],
fileName = 'open-meteo-chart',
pixelRatio = 2,
class: className = '',
controls
}: Props = $props();
// ─── State ──────────────────────────────────────────────────────────────────
let downloadingFormat: ExportFormat | null = $state(null);
// ─── Computed ───────────────────────────────────────────────────────────────
let hasCharts = $derived(charts.length > 0);
// ─── Handlers ───────────────────────────────────────────────────────────────
async function handleDownload(format: ExportFormat): Promise<void> {
if (!hasCharts || downloadingFormat) return;
downloadingFormat = format;
try {
await new Promise((resolve) => setTimeout(resolve, 50));
downloadMeteogram(charts, { fileName, format, pixelRatio });
} finally {
setTimeout(() => {
downloadingFormat = null;
}, 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 buttons -->
<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')}
title="Download meteogram as PNG image"
>
{#if downloadingFormat === 'png'}
<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>
<!-- 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>
<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>
+165
View File
@@ -0,0 +1,165 @@
<!--
EChart.svelte — Reusable ECharts wrapper component
Handles chart lifecycle (init, update, dispose), responsive resize via
ResizeObserver, and exposes the ECharts instance for programmatic access
(e.g. export/download).
Usage:
<EChart
option={chartOption}
height="300px"
renderer="canvas"
onChartReady={(chart) => { ... }}
/>
-->
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import * as echarts from 'echarts';
// ─── Props ──────────────────────────────────────────────────────────────────
interface Props {
/** The ECharts option object to render */
option: Record<string, unknown>;
/** CSS height of the chart container (default: '300px') */
height?: string;
/** CSS width of the chart container (default: '100%') */
width?: string;
/** Renderer type: 'canvas' or 'svg' (default: 'canvas') */
renderer?: 'canvas' | 'svg';
/** Whether to merge options on update (true) or replace them (false) */
notMerge?: boolean;
/** Whether to delay update until next animation frame */
lazyUpdate?: boolean;
/** Optional CSS class for the outer container */
class?: string;
/** Callback fired once the chart instance is initialized */
onChartReady?: (chart: echarts.ECharts) => void;
/** Callback fired when the chart is disposed */
onChartDisposed?: () => void;
}
let {
option,
height = '300px',
width = '100%',
renderer = 'canvas',
notMerge = false,
lazyUpdate = false,
class: className = '',
onChartReady,
onChartDisposed
}: Props = $props();
// ─── Internal State ─────────────────────────────────────────────────────────
let containerEl: HTMLDivElement;
let chartInstance: echarts.ECharts | null = null;
let resizeObserver: ResizeObserver | null = null;
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Returns the underlying ECharts instance, or null if not yet initialized.
*/
export function getChart(): echarts.ECharts | null {
return chartInstance;
}
/**
* Returns true if the chart has been initialized and is not disposed.
*/
export function isReady(): boolean {
return chartInstance !== null && !chartInstance.isDisposed();
}
/**
* Triggers a manual resize of the chart.
* Useful after layout changes that the ResizeObserver might miss.
*/
export function resize(): void {
if (chartInstance && !chartInstance.isDisposed()) {
chartInstance.resize();
}
}
/**
* Disposes the chart instance and cleans up observers.
* Called automatically on component destroy, but can be invoked manually.
*/
export function dispose(): void {
cleanup();
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
initChart();
});
onDestroy(() => {
cleanup();
});
// ─── Reactivity: Update option when it changes ──────────────────────────────
$effect(() => {
if (chartInstance && !chartInstance.isDisposed() && option) {
chartInstance.setOption(option, notMerge, lazyUpdate);
}
});
// ─── Init & Cleanup ─────────────────────────────────────────────────────────
function initChart(): void {
if (!containerEl) return;
// Dispose any existing instance (e.g. from HMR)
if (chartInstance && !chartInstance.isDisposed()) {
chartInstance.dispose();
}
chartInstance = echarts.init(containerEl, null, { renderer });
if (option) {
chartInstance.setOption(option, notMerge, lazyUpdate);
}
// Set up responsive resize
resizeObserver = new ResizeObserver(() => {
if (chartInstance && !chartInstance.isDisposed()) {
chartInstance.resize();
}
});
resizeObserver.observe(containerEl);
onChartReady?.(chartInstance);
}
function cleanup(): void {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (chartInstance) {
if (!chartInstance.isDisposed()) {
chartInstance.dispose();
}
chartInstance = null;
}
onChartDisposed?.();
}
</script>
<div bind:this={containerEl} class="echart-wrapper {className}" style:width style:height></div>
<style>
.echart-wrapper {
position: relative;
overflow: hidden;
}
</style>
+164
View File
@@ -0,0 +1,164 @@
/* ═══════════════════════════════════════════════════════════════════════════════
ECharts Global Styles — Open-Meteo Weather
Shared CSS for all ECharts chart instances across the application.
Provides consistent theming, tooltip styling, and responsive behavior
that integrates with the application's design system (Tailwind + shadcn).
═══════════════════════════════════════════════════════════════════════════════ */
/* ─── Chart Wrapper ────────────────────────────────────────────────────────── */
.echart-wrapper {
width: 100%;
position: relative;
overflow: hidden;
}
/* ─── Chart Container (legacy class support) ───────────────────────────────── */
.echarts-container {
width: 100%;
height: 100%;
min-height: 300px;
}
/* Ensure canvas background is always transparent so our page bg shows through */
.echart-wrapper canvas,
.echarts-container canvas {
background: transparent !important;
}
/* ─── Tooltip Styling ──────────────────────────────────────────────────────── */
/* Override ECharts' default tooltip to match the application's popover design */
.echarts-tooltip {
background: hsl(var(--popover)) !important;
border: 1px solid hsl(var(--border)) !important;
border-radius: var(--radius, 0.5rem) !important;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.05) !important;
padding: 0.625rem 0.75rem !important;
font-size: 0.8125rem !important;
line-height: 1.4 !important;
max-width: min(90vw, 480px) !important;
pointer-events: none;
}
.echarts-tooltip-content {
color: hsl(var(--popover-foreground)) !important;
}
/* Tooltip marker dots — make them slightly larger and rounded */
.echarts-tooltip .echarts-tooltip-marker,
.echarts-tooltip span[style*="border-radius"] {
display: inline-block;
vertical-align: middle;
margin-right: 0.25rem;
}
/* ─── Loading Mask ─────────────────────────────────────────────────────────── */
.echarts-loading-mask {
background: hsl(var(--background) / 0.8) !important;
}
/* ─── Light Mode Adjustments ───────────────────────────────────────────────── */
[data-theme='light'] .echart-wrapper,
[data-theme='light'] .echarts-container,
:root:not(.dark):not([data-theme='dark']) .echart-wrapper,
:root:not(.dark):not([data-theme='dark']) .echarts-container {
color: hsl(var(--foreground));
}
[data-theme='light'] .echarts-tooltip,
:root:not(.dark):not([data-theme='dark']) .echarts-tooltip {
color: hsl(var(--popover-foreground)) !important;
}
/* ─── Dark Mode Adjustments ────────────────────────────────────────────────── */
.dark .echart-wrapper,
[data-theme='dark'] .echart-wrapper,
.dark .echarts-container,
[data-theme='dark'] .echarts-container {
color: hsl(var(--foreground));
}
.dark .echarts-tooltip,
[data-theme='dark'] .echarts-tooltip {
color: hsl(var(--popover-foreground)) !important;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.3),
0 2px 4px -2px rgb(0 0 0 / 0.15) !important;
}
/* ─── Toolbox Icon Overrides ───────────────────────────────────────────────── */
/* Make sure the toolbox icons are visually subtle until hovered */
.echart-wrapper [class*="toolbox"],
.echarts-container [class*="toolbox"] {
opacity: 0.6;
transition: opacity 150ms ease;
}
.echart-wrapper:hover [class*="toolbox"],
.echarts-container:hover [class*="toolbox"] {
opacity: 1;
}
/* ─── Chart Spacing ────────────────────────────────────────────────────────── */
/* Add consistent vertical spacing between stacked chart instances */
.chart-content .echart-wrapper + .echart-wrapper {
margin-top: 0.5rem;
}
/* ─── Responsive Sizing ────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.echarts-container {
min-height: 240px;
}
/* Slightly smaller tooltips on mobile */
.echarts-tooltip {
font-size: 0.75rem !important;
padding: 0.5rem 0.625rem !important;
}
}
@media (min-width: 641px) and (max-width: 1024px) {
.echarts-container {
min-height: 280px;
}
}
/* ─── Print Styles ─────────────────────────────────────────────────────────── */
@media print {
.echart-wrapper,
.echarts-container {
break-inside: avoid;
page-break-inside: avoid;
}
/* Hide interactive elements when printing */
.echart-wrapper [class*="toolbox"],
.echarts-container [class*="toolbox"],
.chart-toolbar {
display: none !important;
}
}
/* ─── Accessibility ────────────────────────────────────────────────────────── */
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.echart-wrapper *,
.echarts-container * {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Chart Components — Barrel Export
*
* Re-exports all chart-related Svelte components from a single entry point.
*
* Usage:
* import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts';
*/
export { default as EChart } from './EChart.svelte';
export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.svelte';
+321
View File
@@ -0,0 +1,321 @@
/**
* ECharts Download Utilities
*
* Provides programmatic chart export functionality for downloading
* charts as PNG or SVG images. Supports stitching multiple chart
* instances into a single combined meteogram image.
*/
import type * as echarts from 'echarts';
// ─── Types ───────────────────────────────────────────────────────────────────
export type ExportFormat = 'png' | 'svg';
export interface DownloadOptions {
/** The file name (without extension) */
fileName?: string;
/** Export format: 'png' or 'svg' */
format?: ExportFormat;
/** Pixel ratio for PNG exports (default: 2 for retina quality) */
pixelRatio?: number;
/** Background color (default: '#ffffff' for PNG, 'none' for SVG) */
backgroundColor?: string;
/** Components to exclude from the export (e.g. ['toolbox']) */
excludeComponents?: string[];
}
// ─── Defaults ────────────────────────────────────────────────────────────────
const DEFAULT_FILE_NAME = 'open-meteo-chart';
const DEFAULT_PIXEL_RATIO = 2;
// ─── Download Functions ──────────────────────────────────────────────────────
/**
* Downloads a single ECharts instance as an image file.
*
* @param chart - The ECharts instance to export
* @param options - Download configuration options
*/
export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = {}): void {
const {
fileName = DEFAULT_FILE_NAME,
format = 'png',
pixelRatio = DEFAULT_PIXEL_RATIO,
backgroundColor,
excludeComponents = ['toolbox']
} = options;
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
const dataUrl = chart.getDataURL({
type: format === 'svg' ? 'svg' : 'png',
pixelRatio: format === 'png' ? pixelRatio : 1,
backgroundColor: resolvedBg,
excludeComponents
});
triggerDownload(dataUrl, `${fileName}.${format}`);
}
/**
* Downloads multiple ECharts instances stitched into a single combined
* meteogram image. Charts are stacked vertically in the order provided.
*
* For a single chart, delegates to `downloadChart`.
*
* @param charts - Array of ECharts instances to combine
* @param options - Download configuration options
*/
export function downloadMeteogram(charts: echarts.ECharts[], options: DownloadOptions = {}): void {
const validCharts = charts.filter((c) => c && !c.isDisposed());
if (validCharts.length === 0) return;
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
});
}
/**
* Returns the data URL of a chart without triggering a download.
* Useful for previewing or embedding chart images programmatically.
*
* @param chart - The ECharts instance to export
* @param options - Export configuration options
* @returns A base64-encoded data URL string
*/
export function getChartDataUrl(chart: echarts.ECharts, options: DownloadOptions = {}): string {
const {
format = 'png',
pixelRatio = DEFAULT_PIXEL_RATIO,
backgroundColor,
excludeComponents = ['toolbox']
} = options;
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
return chart.getDataURL({
type: format === 'svg' ? 'svg' : 'png',
pixelRatio: format === 'png' ? pixelRatio : 1,
backgroundColor: resolvedBg,
excludeComponents
});
}
// ─── 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 or object URL.
* Creates a temporary anchor element, clicks it, and removes it.
*/
function triggerDownload(url: string, fileName: string): void {
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
requestAnimationFrame(() => {
document.body.removeChild(link);
});
}
+72
View File
@@ -0,0 +1,72 @@
/**
* ECharts Utilities — Barrel Export
*
* Re-exports all ECharts-related utilities from a single entry point.
*
* Usage:
* import { getThemeColors, composeChartOption, buildModelSeries, downloadChart } from '$lib/utils/echarts';
*/
// Theme: dark/light detection, color palettes, theme color accessors
export {
SERIES_COLORS,
CHART_COLORS,
isDarkMode,
getThemeColors,
getTextColor,
getAxisLineColor,
getSplitLineColor
} from './theme';
export type { ThemeColors } from './theme';
// Option builders: grid, title, tooltip, legend, axes, toolbox, full composer
export {
buildGrid,
buildTitle,
buildTooltip,
buildLegend,
buildTimeXAxis,
buildValueYAxis,
buildCreditGraphic,
buildToolbox,
composeChartOption,
isColumnUnit
} from './options';
export type {
GridOptions,
TitleOptions,
LegendOptions,
TooltipOptions,
AxisOptions,
CreditOptions,
BuildGridParams,
ToolboxOptions,
ChartOptionParams
} from './options';
// Series builders: model lines, averages, time markers, daylight bands, ensemble spread
export {
buildModelSeries,
buildAverageSeries,
buildCurrentTimeSeries,
buildDaylightMarkAreas,
buildDaylightSeries,
buildSpreadSeries,
calculateAverage,
calculateSpread,
convertTimestamps,
findUnit
} from './series';
export type {
ModelSeriesParams,
AverageSeriesParams,
CurrentTimeSeriesParams,
DaylightSeriesParams,
SpreadSeriesParams,
AverageResult,
SpreadResult
} from './series';
// Download: export charts as PNG or SVG
export { downloadChart, downloadMeteogram, getChartDataUrl } from './download';
export type { ExportFormat, DownloadOptions } from './download';
+392
View File
@@ -0,0 +1,392 @@
/**
* ECharts Option Builders
*
* Shared factory functions for constructing common ECharts option fragments.
* These builders ensure visual consistency across all chart pages and reduce
* boilerplate in page-level components.
*/
import { getThemeColors } from './theme';
import type { ThemeColors } from './theme';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface GridOptions {
left?: number;
right?: number;
top?: number;
bottom?: number;
}
export interface TitleOptions {
text: string;
subtext?: string;
}
export interface LegendOptions {
show: boolean;
data?: string[];
}
export interface TooltipOptions {
unit: string;
}
export interface AxisOptions {
unit?: string;
}
export interface CreditOptions {
show: boolean;
}
// ─── Default Constants ───────────────────────────────────────────────────────
const DEFAULT_GRID: GridOptions = {
left: 60,
right: 16,
top: 40,
bottom: 40
};
const GRID_WITH_TITLE: Partial<GridOptions> = {
top: 80
};
const GRID_WITH_LEGEND: Partial<GridOptions> = {
bottom: 60
};
// ─── Grid ────────────────────────────────────────────────────────────────────
export interface BuildGridParams {
hasTitle?: boolean;
hasSubtitle?: boolean;
showLegend?: boolean;
overrides?: Partial<GridOptions>;
}
/**
* Builds a grid configuration with sensible defaults.
* Automatically adjusts top/bottom spacing for title and legend presence.
*/
export function buildGrid(params: BuildGridParams = {}): GridOptions {
const { hasTitle = false, hasSubtitle = false, showLegend = false, overrides } = params;
return {
...DEFAULT_GRID,
...(hasTitle ? GRID_WITH_TITLE : {}),
...(hasSubtitle ? { top: 90 } : {}),
...(showLegend ? GRID_WITH_LEGEND : {}),
...overrides
};
}
// ─── Title ───────────────────────────────────────────────────────────────────
/**
* Builds a title configuration. Pass `null` to hide the title.
*/
export function buildTitle(
options: TitleOptions | null,
colors?: ThemeColors
): Record<string, unknown> {
const c = colors ?? getThemeColors();
if (!options) {
return { title: { show: false } };
}
const result: Record<string, unknown> = {
text: options.text,
left: 'left',
textStyle: {
fontWeight: 'normal',
fontSize: 16,
color: c.text
}
};
if (options.subtext) {
result.subtext = options.subtext;
result.subtextStyle = {
fontWeight: 'normal',
fontSize: 12,
color: c.textMuted
};
}
return result;
}
// ─── Tooltip ─────────────────────────────────────────────────────────────────
/**
* Builds a tooltip with cross-axis pointer and unit-aware value formatting.
*/
export function buildTooltip(
options: TooltipOptions,
colors?: ThemeColors
): Record<string, unknown> {
const c = colors ?? getThemeColors();
const { unit } = options;
return {
trigger: 'axis',
axisPointer: {
type: 'cross',
animation: false,
label: {
backgroundColor: c.tooltipBg,
color: c.text,
borderColor: c.tooltipBorder,
borderWidth: 1
}
},
backgroundColor: c.tooltipBg,
borderColor: c.tooltipBorder,
textStyle: {
color: c.text
},
valueFormatter: (value: number) => {
if (value === null || value === undefined) return '-';
return value.toFixed(1) + ' ' + unit;
}
};
}
// ─── Legend ───────────────────────────────────────────────────────────────────
/**
* Builds a scrollable legend configuration pinned to the bottom.
*/
export function buildLegend(options: LegendOptions, colors?: ThemeColors): Record<string, unknown> {
const c = colors ?? getThemeColors();
return {
show: options.show,
bottom: 0,
type: 'scroll',
...(options.data ? { data: options.data } : {}),
textStyle: {
color: c.text
},
pageTextStyle: {
color: c.text
}
};
}
// ─── X Axis (Time) ───────────────────────────────────────────────────────────
/**
* Builds a time-based X axis with theme-aware styling.
*/
export function buildTimeXAxis(colors?: ThemeColors): Record<string, unknown> {
const c = colors ?? getThemeColors();
return {
type: 'time',
splitLine: {
show: false
},
axisLine: {
lineStyle: {
color: c.axisLine
}
},
axisLabel: {
color: c.text,
hideOverlap: true
},
axisTick: {
lineStyle: {
color: c.axisLine
}
}
};
}
// ─── Y Axis (Value) ─────────────────────────────────────────────────────────
/**
* Builds a value-based Y axis with optional unit label.
*/
export function buildValueYAxis(
options: AxisOptions = {},
colors?: ThemeColors
): Record<string, unknown> {
const c = colors ?? getThemeColors();
return {
type: 'value',
...(options.unit ? { name: options.unit } : {}),
nameTextStyle: {
color: c.text,
padding: [0, 0, 0, 4]
},
axisLine: {
show: false
},
axisLabel: {
color: c.text
},
splitLine: {
lineStyle: {
color: c.splitLine
}
}
};
}
// ─── Credit Watermark ────────────────────────────────────────────────────────
/**
* Builds the Open-Meteo.com credit watermark graphic element.
*/
export function buildCreditGraphic(colors?: ThemeColors): Record<string, unknown>[] {
const c = colors ?? getThemeColors();
return [
{
type: 'text',
right: 10,
bottom: 5,
style: {
text: 'Open-Meteo.com',
fontSize: 10,
fill: c.text,
opacity: 0.4
},
onclick: function () {
window.open('https://open-meteo.com', '_blank');
},
cursor: 'pointer'
}
];
}
// ─── Toolbox (Download) ─────────────────────────────────────────────────────
export interface ToolboxOptions {
/** Show the save-as-image button */
saveAsImage?: boolean;
/** File name prefix for downloaded images */
fileName?: string;
/** Export format: 'png' or 'svg' */
format?: 'png' | 'svg';
}
/**
* Builds the ECharts toolbox with download functionality.
*/
export function buildToolbox(
options: ToolboxOptions = {},
colors?: ThemeColors
): Record<string, unknown> {
const c = colors ?? getThemeColors();
const { saveAsImage = true, fileName = 'open-meteo-chart', format = 'png' } = options;
return {
show: true,
right: 16,
top: 4,
iconStyle: {
borderColor: c.textMuted
},
emphasis: {
iconStyle: {
borderColor: c.text
}
},
feature: {
...(saveAsImage
? {
saveAsImage: {
type: format,
name: fileName,
title: format === 'svg' ? 'Save as SVG' : 'Save as PNG',
pixelRatio: 2,
backgroundColor: 'transparent',
excludeComponents: ['toolbox'],
iconStyle: {
borderColor: c.textMuted
},
emphasis: {
iconStyle: {
borderColor: c.text
}
}
}
}
: {})
}
};
}
// ─── Full Option Composer ────────────────────────────────────────────────────
export interface ChartOptionParams {
title?: TitleOptions | null;
tooltip: TooltipOptions;
legend?: LegendOptions;
grid?: BuildGridParams;
yAxis?: AxisOptions;
series: Array<Record<string, unknown>>;
toolbox?: ToolboxOptions | false;
showCredit?: boolean;
colors?: ThemeColors;
}
/**
* Composes a complete ECharts option object from individual builder params.
* This is the primary entry point for building chart options — it calls all
* the individual builders and merges the results into a single config object.
*/
export function composeChartOption(params: ChartOptionParams): Record<string, unknown> {
const colors = params.colors ?? getThemeColors();
const hasTitle = params.title != null && params.title.text !== '';
const showLegend = params.legend?.show ?? false;
const option: Record<string, unknown> = {
title: buildTitle(params.title ?? null, colors),
tooltip: buildTooltip(params.tooltip, colors),
legend: buildLegend(params.legend ?? { show: false }, colors),
grid: buildGrid({
...params.grid,
hasTitle,
hasSubtitle: hasTitle && !!params.title?.subtext,
showLegend
}),
xAxis: buildTimeXAxis(colors),
yAxis: buildValueYAxis(params.yAxis, colors),
series: params.series,
textStyle: {
color: colors.text
}
};
// Add toolbox unless explicitly disabled
if (params.toolbox !== false) {
option.toolbox = buildToolbox(params.toolbox ?? {}, colors);
}
// Add credit watermark
if (params.showCredit) {
option.graphic = buildCreditGraphic(colors);
}
return option;
}
// ─── Utility: Detect column-type variables ───────────────────────────────────
/** Units that should be rendered as bar/column charts instead of lines. */
const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
/**
* Returns true if the given unit should be rendered as a bar chart.
*/
export function isColumnUnit(unit: string): boolean {
return COLUMN_UNITS.has(unit);
}
+364
View File
@@ -0,0 +1,364 @@
/**
* ECharts Series Builders
*
* Factory functions for constructing common series patterns used across
* weather chart visualizations. These builders encapsulate the styling
* and configuration details so page-level code only needs to provide data.
*/
import { CHART_COLORS } from './theme';
import { isColumnUnit } from './options';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface ModelSeriesParams {
/** The series name (typically the model key from the API response) */
name: string;
/** Array of [timestamp, value] data points */
data: Array<[number, number | null]>;
/** The unit string, used to determine bar vs line rendering */
unit: string;
/** Optional line width override (default: 2) */
lineWidth?: number;
}
export interface AverageSeriesParams {
/** The variable name, used to construct the series name */
variable: string;
/** Array of [timestamp, value] data points */
data: Array<[number, number]>;
/** The unit string, used to determine bar vs line rendering */
unit: string;
}
export interface CurrentTimeSeriesParams {
/** UTC offset in seconds from the API response */
utcOffsetSeconds: number;
}
export interface DaylightSeriesParams {
/** Array of mark area pairs: [[start, end], [start, end], ...] */
markAreas: Array<
[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]
>;
}
export interface SpreadSeriesParams {
/** The variable name, used to construct series names */
variable: string;
/** Array of [timestamp, min, max] data points */
spreadData: Array<[number, number, number]>;
/** Optional color for the spread area (default: theme spread color) */
color?: string;
}
export interface MarkAreaEntry {
xAxis: number;
itemStyle?: { color: string };
}
// ─── Model Series ────────────────────────────────────────────────────────────
/**
* Builds a single model series (line or bar depending on the unit).
* Used on the Model Comparison page where each weather model gets its own series.
*/
export function buildModelSeries(params: ModelSeriesParams): Record<string, unknown> {
const { name, data, unit, lineWidth = 2 } = params;
const isColumn = isColumnUnit(unit);
return {
name,
type: isColumn ? 'bar' : 'line',
data,
smooth: !isColumn,
showSymbol: false,
lineStyle: {
width: lineWidth
},
emphasis: {
lineStyle: {
width: lineWidth + 1
}
},
barMaxWidth: 5
};
}
// ─── Average Series ──────────────────────────────────────────────────────────
/**
* Builds the ensemble/model average series.
* Rendered as a dashed line (or bar) that stands out from individual model lines.
*/
export function buildAverageSeries(params: AverageSeriesParams): Record<string, unknown> {
const { variable, data, unit } = params;
const isColumn = isColumnUnit(unit);
return {
name: variable + '_average',
type: isColumn ? 'bar' : 'line',
data,
smooth: !isColumn,
showSymbol: false,
lineStyle: {
type: 'dashed',
width: 4,
color: CHART_COLORS.average
},
itemStyle: {
color: CHART_COLORS.average
},
emphasis: {
lineStyle: {
width: 6
}
},
barMaxWidth: 5,
z: 10
};
}
// ─── Current Time Marker ─────────────────────────────────────────────────────
/**
* Builds a helper series that renders a vertical red line at the current time.
* Uses an empty data series with a markLine to overlay onto the chart.
*/
export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record<string, unknown> {
const { utcOffsetSeconds } = params;
return {
name: 'Current Time',
type: 'line',
data: [],
markLine: {
silent: true,
symbol: 'none',
data: [
{
xAxis: Date.now() + utcOffsetSeconds * 1000,
lineStyle: {
color: CHART_COLORS.currentTimeLine,
width: 2,
type: 'solid'
},
label: {
show: false
}
}
]
}
};
}
// ─── Daylight Bands ──────────────────────────────────────────────────────────
/**
* Builds mark area entries from sunrise/sunset arrays.
* Each entry is a pair of axis markers that ECharts renders as a shaded band.
*
* @param sunrise - Array of sunrise timestamps (unix seconds, without UTC offset)
* @param sunset - Array of sunset timestamps (unix seconds, without UTC offset)
* @param utcOffsetSeconds - UTC offset to apply (from the API response)
*/
export function buildDaylightMarkAreas(
sunrise: number[],
sunset: number[],
utcOffsetSeconds: number
): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> {
return sunrise.map((r: number, i: number) => [
{
xAxis: (r + utcOffsetSeconds) * 1000,
itemStyle: {
color: CHART_COLORS.daylight
}
},
{
xAxis: (sunset[i] + utcOffsetSeconds) * 1000
}
]);
}
/**
* Builds a helper series that renders day/night shading bands via markArea.
* Returns null if no mark areas are provided (so callers can filter it out).
*/
export function buildDaylightSeries(
params: DaylightSeriesParams
): Record<string, unknown> | null {
if (params.markAreas.length === 0) return null;
return {
name: 'Daylight',
type: 'line',
data: [],
markArea: {
silent: true,
data: params.markAreas
}
};
}
// ─── Ensemble Spread (Min/Max Area) ──────────────────────────────────────────
/**
* Builds a pair of stacked area series that visualize the ensemble spread
* (min-to-max range). The lower bound is rendered invisibly and the upper
* bound delta is stacked on top with a translucent fill.
*
* Returns an array of two series that should be spread into the series list.
*/
export function buildSpreadSeries(params: SpreadSeriesParams): Array<Record<string, unknown>> {
const { variable, spreadData, color = CHART_COLORS.spreadArea } = params;
const lowerBound: Record<string, unknown> = {
name: variable + '_spread_lower',
type: 'line',
data: spreadData.map((d) => [d[0], d[1]]),
areaStyle: {
color,
origin: 'auto'
},
lineStyle: {
width: 0
},
showSymbol: false,
stack: 'spread_' + variable,
smooth: true,
z: 1,
silent: true
};
const upperDelta: Record<string, unknown> = {
name: variable + '_spread_upper',
type: 'line',
data: spreadData.map((d) => [d[0], d[2] - d[1]]),
areaStyle: {
color,
origin: 'auto'
},
lineStyle: {
width: 0
},
showSymbol: false,
stack: 'spread_' + variable,
smooth: true,
z: 1,
silent: true
};
return [lowerBound, upperDelta];
}
// ─── Data Processing Helpers ─────────────────────────────────────────────────
export interface AverageResult {
average: number[];
averageCount: number[];
}
export interface SpreadResult {
minValues: (number | undefined)[];
maxValues: (number | undefined)[];
}
/**
* Calculates per-timestep average and count from hourly model data.
* Shared between the Model Compare and 14-Day Forecast pages.
*
* @param hourlyData - The `data.hourly` object from the API response
* @param variable - The variable prefix to filter on (e.g. 'temperature_2m')
* @param timeLength - Number of timesteps
* @returns Object containing running average and count arrays
*/
export function calculateAverage(
hourlyData: Record<string, unknown>,
variable: string,
timeLength: number
): AverageResult {
const average = new Array<number>(timeLength).fill(0);
const averageCount = new Array<number>(timeLength).fill(0);
for (const [model, values] of Object.entries(hourlyData)) {
if (model === 'time') continue;
if (!model.startsWith(variable)) continue;
for (const [index, val] of (values as number[]).entries()) {
if (val !== null && val !== undefined) {
average[index] += val;
averageCount[index]++;
}
}
}
// Finalize average values
for (let i = 0; i < timeLength; i++) {
if (averageCount[i] > 0) {
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
}
}
return { average, averageCount };
}
/**
* Calculates per-timestep min and max values from hourly ensemble data.
* Used by the 14-Day Forecast page to render the ensemble spread.
*
* @param hourlyData - The `data.hourly` object from the API response
* @param variable - The variable prefix to filter on
* @param timeLength - Number of timesteps
* @returns Object containing min and max value arrays
*/
export function calculateSpread(
hourlyData: Record<string, unknown>,
variable: string,
timeLength: number
): SpreadResult {
const minValues = new Array<number | undefined>(timeLength).fill(undefined);
const maxValues = new Array<number | undefined>(timeLength).fill(undefined);
for (const [model, values] of Object.entries(hourlyData)) {
if (model === 'time') continue;
if (!model.startsWith(variable)) continue;
for (const [index, val] of (values as number[]).entries()) {
if (val !== null && val !== undefined) {
if (minValues[index] === undefined || val < minValues[index]!) {
minValues[index] = val;
}
if (maxValues[index] === undefined || val > maxValues[index]!) {
maxValues[index] = val;
}
}
}
}
return { minValues, maxValues };
}
/**
* Converts raw unix timestamps (seconds) to millisecond timestamps with UTC offset applied.
*/
export function convertTimestamps(times: number[], utcOffsetSeconds: number): number[] {
return times.map((t) => (t + utcOffsetSeconds) * 1000);
}
/**
* Finds the unit string for a given variable from the hourly_units map.
* Returns an empty string if the variable is not found.
*/
export function findUnit(
hourlyUnits: Record<string, string>,
hourlyData: Record<string, unknown>,
variable: string
): string {
for (const model of Object.keys(hourlyData)) {
if (model === 'time') continue;
if (model.startsWith(variable) && hourlyUnits[model]) {
return hourlyUnits[model];
}
}
return '';
}
+106
View File
@@ -0,0 +1,106 @@
/**
* ECharts Theme Utilities
*
* Centralized dark/light mode detection and color helpers for consistent
* chart theming across all ECharts visualizations.
*/
// ─── Color Palette ───────────────────────────────────────────────────────────
/** Default series color palette matching the application's design system */
export const SERIES_COLORS = [
'#5470c6',
'#91cc75',
'#fac858',
'#ee6666',
'#73c0de',
'#3ba272',
'#fc8452',
'#9a60b4',
'#ea7ccc',
'#4dc9f6'
] as const;
/** Semantic colors used for specific chart elements */
export const CHART_COLORS = {
average: '#5e5e5e',
currentTimeLine: '#ef4444',
daylight: 'rgba(255, 255, 194, 0.3)',
spreadArea: 'rgba(173, 216, 230, 0.3)',
creditText: { light: '#374151', dark: '#e5e7eb' }
} as const;
// ─── Dark Mode Detection ─────────────────────────────────────────────────────
/**
* Detects whether the application is currently in dark mode.
* Checks multiple sources: HTML class, data-theme attribute, and media query.
*/
export function isDarkMode(): boolean {
if (typeof document === 'undefined') return false;
const html = document.documentElement;
const dataTheme = html.getAttribute('data-theme');
// Explicit data-theme takes priority
if (dataTheme === 'dark') return true;
if (dataTheme === 'light') return false;
// Check for dark class (e.g. Tailwind dark mode)
if (html.classList.contains('dark')) return true;
// Fall back to system preference
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
}
// ─── Theme Colors ────────────────────────────────────────────────────────────
export interface ThemeColors {
text: string;
textMuted: string;
axisLine: string;
splitLine: string;
background: string;
tooltipBg: string;
tooltipBorder: string;
}
const LIGHT_COLORS: ThemeColors = {
text: '#374151',
textMuted: 'rgba(55, 65, 81, 0.6)',
axisLine: 'rgba(55, 65, 81, 0.3)',
splitLine: 'rgba(55, 65, 81, 0.1)',
background: 'transparent',
tooltipBg: '#ffffff',
tooltipBorder: '#e5e7eb'
};
const DARK_COLORS: ThemeColors = {
text: '#e5e7eb',
textMuted: 'rgba(229, 231, 235, 0.6)',
axisLine: 'rgba(229, 231, 235, 0.3)',
splitLine: 'rgba(229, 231, 235, 0.1)',
background: 'transparent',
tooltipBg: '#1f2937',
tooltipBorder: '#374151'
};
/**
* Returns the full set of theme colors based on current dark/light mode.
*/
export function getThemeColors(): ThemeColors {
return isDarkMode() ? DARK_COLORS : LIGHT_COLORS;
}
/** Shorthand helpers kept for backward compatibility and convenience */
export function getTextColor(): string {
return getThemeColors().text;
}
export function getAxisLineColor(): string {
return getThemeColors().axisLine;
}
export function getSplitLineColor(): string {
return getThemeColors().splitLine;
}
+1
View File
@@ -0,0 +1 @@
export const prerender = true;
+1 -1
View File
@@ -209,7 +209,7 @@
</div>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{#each [{ title: 'Multiple Models', desc: 'Compare ECMWF, GFS, and more', icon: 'layers' }, { title: 'Extended Forecasts', desc: 'Up to 14 days ahead', icon: 'clock' }, { title: 'Interactive Charts', desc: 'Highcharts visualization', icon: 'bar-chart' }, { title: 'Real-time Data', desc: 'Always up-to-date', icon: 'refresh' }] as feature, index (feature.title)}
{#each [{ title: 'Multiple Models', desc: 'Compare ECMWF, GFS, and more', icon: 'layers' }, { title: 'Extended Forecasts', desc: 'Up to 14 days ahead', icon: 'clock' }, { title: 'Interactive Charts', desc: 'Apache ECharts visualization', icon: 'bar-chart' }, { title: 'Real-time Data', desc: 'Always up-to-date', icon: 'refresh' }] as feature, index (feature.title)}
<div class="text-center" in:fly={{ y: 20, duration: 500, delay: 1200 + index * 100 }}>
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-purple-500"
-2
View File
@@ -2,8 +2,6 @@ import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const prerender = true;
export const load = (async () => {
throw redirect(303, '/weather/week/');
}) satisfies PageLoad;
+203 -295
View File
@@ -1,28 +1,46 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { dev } from '$app/environment';
import { storedLocation } from '$lib/stores/settings';
import {
buildAverageSeries,
buildCurrentTimeSeries,
buildDaylightMarkAreas,
buildDaylightSeries,
buildSpreadSeries,
calculateAverage,
calculateSpread,
composeChartOption,
convertTimestamps,
findUnit,
getThemeColors
} from '$lib/utils/echarts';
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
import '$lib/components/charts/echarts.css';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import '../compare/highcharts.css';
import { defaultParameters } from './options';
import { defaultParameters } from '../options';
let node: HTMLElement;
let chart: any;
let Highcharts = $state<typeof import('highcharts') | null>(null);
import type * as echarts from 'echarts';
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
let averageOnly = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]);
let mounted = $state(false);
let loading = $state(true);
const location = get(storedLocation);
// Local component state for chart configuration
let params = $state({
latitude: [52.52],
longitude: [13.41],
@@ -31,301 +49,191 @@
models: ['gfs_seamless']
});
let count = $state(0);
onMount(async () => {
/// Highcharts needs to be loaded in `onMount` to work with prerendered SSG
Highcharts = (await import('highcharts')).default;
const more = (await import('highcharts/highcharts-more')).default;
// more(Highcharts);
// ─── Cached API Response ────────────────────────────────────────────────────
if (dev) {
// const HighchartsDebugger = await import('highcharts/modules/debugger');
// HighchartsDebugger.default(Highcharts);
const Debugger = (
await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any)
).default;
const ErrorMessages = (
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any)
).default;
if (Highcharts) {
(Highcharts as any).errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart);
}
}
});
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[];
}
$effect(() => {
const loadData = async () => {
count = 0;
if (Highcharts) {
node.replaceChildren();
let fetchedData: FetchedData | null = $state(null);
const dataDaily = await fetch(
`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();
// ─── Lifecycle ──────────────────────────────────────────────────────────────
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();
let plotBands: any = [];
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
let rise = wd.daily.sunrise;
let set = wd.daily.sunset;
plotBands = rise.map(function (r: any, i: number) {
return {
color: 'rgba(255, 255, 194, 0.5)',
from: (r + data.utc_offset_seconds) * 1000,
to: (set[i] + data.utc_offset_seconds) * 1000
};
});
}
let minValues = new Array(data.hourly.time.length).fill(undefined);
let maxValues = new Array(data.hourly.time.length).fill(undefined);
for (let variable of params.hourly || []) {
const chartDiv = document.createElement('div');
let unit;
let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
const series = [];
let average = new Array(data.hourly.time.length).fill(0);
let averageCount = new Array(data.hourly.time.length).fill(0);
for (let [model, values] of Object.entries(data.hourly)) {
if (model === 'time') {
continue;
}
if (model.startsWith(variable)) {
for (let [index, val] of (values as any[]).entries()) {
if (val) {
let avVal = average[index];
average[index] = avVal + val;
averageCount[index]++;
if (minValues[index] > val || minValues[index] === undefined) {
minValues[index] = val;
}
if (maxValues[index] < val || maxValues[index] === undefined) {
maxValues[index] = val;
}
}
}
unit = data.hourly_units[model];
}
}
for (let [index, val] of average.entries()) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
}
const minMax = [];
for (let [index, min] of minValues.entries()) {
minMax.push([min, maxValues[index]]);
}
series.push({
name: 'temperature_2m_spread',
data: minMax,
type: 'arearange',
tooltip: {
valueSuffix: ' ' + unit
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-spread-series'
});
series.push({
name: variable + '_average',
data: average,
dashStyle: 'ShortDashDot',
color: '#5e5e5e',
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
lineWidth: 4,
states: {
hover: {
lineWidth: 6
}
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-average-series'
});
new Highcharts!.Chart({
chart: {
renderTo: chartDiv,
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: 50,
marginRight: 0
},
credits: {
text: count === (params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
lang: {
locale: 'en-GB'
},
title: {
text: count === 0 ? 'Model Spread' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${params.hourly?.join(', ') || ''}</span> in models: <span class="font-bold">` +
(params.models?.join(', ') || '') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
}
},
xAxis: {
type: 'datetime',
plotLines: [
{
value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
plotOptions: {
spline: {
lineWidth: 2,
states: {
hover: {
lineWidth: 3
}
},
marker: {
enabled: false
}
},
column: {
pointWidth: 5
}
},
legend: {
enabled: showLegend,
layout: 'horizontal',
align: 'center',
verticalAlign: 'bottom'
},
series: series as any,
responsive: {
rules: [
{
condition: {
maxWidth: 800
}
}
]
},
tooltip: {
shared: true,
animation: false
}
});
count++;
node.appendChild(chartDiv);
}
}
};
loadData();
onMount(() => {
mounted = true;
});
onDestroy(() => {
if (chart) {
chart.destroy();
chartInstances = [];
chartOptions = [];
chartComponents = [];
});
// ─── Chart Instance Tracking ────────────────────────────────────────────────
function handleChartReady(chart: echarts.ECharts): void {
chartInstances = [...chartInstances, chart];
}
// ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => {
const hourlyVars = params.hourly;
const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loadData = async () => {
loading = true;
chartInstances = [];
chartComponents = [];
const [dataDaily, dataReq] = await Promise.all([
fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
),
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`
)
]);
const [wd, data] = await Promise.all([dataDaily.json(), dataReq.json()]);
let markAreas: FetchedData['markAreas'] = [];
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
markAreas = buildDaylightMarkAreas(
wd.daily.sunrise,
wd.daily.sunset,
data.utc_offset_seconds
);
}
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 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>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * (params.hourly?.length || 0) +
2}px]"
>
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
<div
class="{count > 0
? 'pointer-events-none opacity-0'
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent/100"
>
<svg
class="lucide lucide-loader-circle animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="hidden">Loading...</span>
</div>
</div>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
<div class="">
<div class="mt-6 flex flex-col items-center gap-6 md:mt-12 md:flex-row">
<div class="flex gap-2">
<Switch
id="show_legend"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
</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-[2px] cursor-pointer text-lg">Average only</Label>
</div>
</div>
<ChartContainer
{loading}
chartCount={params.hourly?.length || 0}
chartHeight={showLegend ? 400 : 300}
>
{#each chartOptions as option, i (i)}
<EChart
{option}
height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady}
bind:this={chartComponents[i]}
/>
{/each}
</ChartContainer>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10">
<ChartToolbar charts={chartInstances} fileName="14-day-forecast">
{#snippet controls()}
<div class="flex gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
</div>
{/snippet}
</ChartToolbar>
</div>
-7
View File
@@ -1,7 +0,0 @@
// Default configuration for 14-day ensemble forecast charts
export const defaultParameters = {
timeformat: 'iso8601',
wind_speed_unit: 'kmh',
temperature_unit: 'celsius',
precipitation_unit: 'mm'
};
+206 -279
View File
@@ -3,27 +3,45 @@
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { dev } from '$app/environment';
import { storedLocation } from '$lib/stores/settings';
import {
buildAverageSeries,
buildCurrentTimeSeries,
buildDaylightMarkAreas,
buildDaylightSeries,
buildModelSeries,
calculateAverage,
composeChartOption,
convertTimestamps,
findUnit,
getThemeColors
} from '$lib/utils/echarts';
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
import '$lib/components/charts/echarts.css';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { hourly, models as modelsFlat } from '../options';
import './highcharts.css';
import { defaultParameters } from './options';
import { defaultParameters } from '../options';
import type * as echarts from 'echarts';
// Wrap models in array to match template expectation of nested arrays like hourly
const models = [modelsFlat];
let node: HTMLElement;
let chart: any;
let Highcharts = $state<typeof import('highcharts') | null>(null);
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
let averageOnly = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]);
let mounted = $state(false);
let loading = $state(true);
const location = get(storedLocation);
@@ -41,300 +59,210 @@
]
});
let count = $state(0);
onMount(async () => {
/// Highcharts needs to be loaded in `onMount` to work with prerendered SSG
Highcharts = (await import('highcharts')).default;
// ─── Cached API Response ────────────────────────────────────────────────────
if (dev) {
// const HighchartsDebugger = await import('highcharts/modules/debugger');
// HighchartsDebugger.default(Highcharts);
const Debugger = (
await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any)
).default;
const ErrorMessages = (
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any)
).default;
if (Highcharts) {
(Highcharts as any).errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart);
}
}
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 ──────────────────────────────────────────────────────────────
onMount(() => {
mounted = true;
});
onDestroy(() => {
chartInstances = [];
chartOptions = [];
chartComponents = [];
});
// ─── Chart Instance Tracking ────────────────────────────────────────────────
function handleChartReady(chart: echarts.ECharts): void {
chartInstances = [...chartInstances, chart];
}
// ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => {
const hourlyVars = params.hourly;
const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loadData = async () => {
count = 0;
if (Highcharts) {
node.replaceChildren();
loading = true;
chartInstances = [];
chartComponents = [];
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`
);
const data = await dataReq.json();
const dataReq = await fetch(
`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();
let markAreas: FetchedData['markAreas'] = [];
if ('daily' in data) {
let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
dailyFirstModelKey.shift();
dailyFirstModelKey = dailyFirstModelKey.join('_');
let plotBands: any = [];
if (
'daily' in data &&
'sunrise_' + dailyFirstModelKey in data.daily &&
'sunset_' + dailyFirstModelKey in data.daily
) {
let rise = data.daily['sunrise_' + dailyFirstModelKey];
let set = data.daily['sunset_' + dailyFirstModelKey];
plotBands = rise.map(function (r: any, i: number) {
return {
color: 'rgba(255, 255, 194, 0.5)',
from: (r + data.utc_offset_seconds) * 1000,
to: (set[i] + data.utc_offset_seconds) * 1000
};
});
}
const sunriseKey = 'sunrise_' + dailyFirstModelKey;
const sunsetKey = 'sunset_' + dailyFirstModelKey;
for (let variable of params.hourly || []) {
const chartDiv = document.createElement('div');
let unit;
let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
const series = [];
let average = new Array(data.hourly.time.length).fill(0);
let averageCount = new Array(data.hourly.time.length).fill(0);
for (let [model, values] of Object.entries(data.hourly)) {
if (model === 'time') {
continue;
}
if (model.startsWith(variable)) {
for (let [index, val] of (values as any[]).entries()) {
if (val) {
let avVal = average[index];
average[index] = avVal + val;
averageCount[index]++;
}
}
unit = data.hourly_units[model];
if (!averageOnly) {
series.push({
name: model,
data: values,
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
pointStart: hourly_starttime,
pointInterval: pointInterval
});
}
}
}
for (let [index, val] of average.entries()) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
}
series.push({
name: variable + '_average',
data: average,
dashStyle: 'ShortDashDot',
color: '#5e5e5e',
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
lineWidth: 4,
states: {
hover: {
lineWidth: 6
}
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-average-series'
});
new Highcharts!.Chart({
chart: {
renderTo: chartDiv,
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: 50,
marginRight: 0
},
credits: {
text: count === (params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
lang: {
locale: 'en-GB'
},
title: {
text: count === 0 ? 'Model Compare' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${params.hourly?.join(', ') || ''}</span> in models: <span class="font-bold">` +
(params.models?.join(', ') || '') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
}
},
xAxis: {
type: 'datetime',
plotLines: [
{
value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
plotOptions: {
spline: {
lineWidth: 2,
states: {
hover: {
lineWidth: 3
}
},
marker: {
enabled: false
}
},
column: {
pointWidth: 5
}
},
legend: {
enabled: showLegend,
layout: 'horizontal',
align: 'center',
verticalAlign: 'bottom'
},
series: series as any,
responsive: {
rules: [
{
condition: {
maxWidth: 800
}
}
]
},
tooltip: {
shared: true,
animation: false
}
});
count++;
node.appendChild(chartDiv);
if (sunriseKey in data.daily && sunsetKey in data.daily) {
markAreas = buildDaylightMarkAreas(
data.daily[sunriseKey],
data.daily[sunsetKey],
data.utc_offset_seconds
);
}
}
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();
});
onDestroy(() => {
if (chart) {
chart.destroy();
// ─── 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>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * (params.hourly?.length || 0) +
2}px]"
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
<ChartContainer
{loading}
chartCount={params.hourly?.length || 0}
chartHeight={showLegend ? 400 : 300}
>
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
<div
class="{count > 0
? 'pointer-events-none opacity-0'
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent/100"
>
<svg
class="lucide lucide-loader-circle animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="hidden">Loading...</span>
</div>
{#each chartOptions as option, i (i)}
<EChart
{option}
height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady}
bind:this={chartComponents[i]}
/>
{/each}
</ChartContainer>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10">
<ChartToolbar charts={chartInstances} fileName="model-comparison">
{#snippet controls()}
<div class="flex gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
</div>
{/snippet}
</ChartToolbar>
</div>
<div class="">
<div class="mt-6 flex flex-col items-center gap-6 md:mt-12 md:flex-row">
<div class="flex gap-2">
<Switch
id="show_legend"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
</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-[2px] cursor-pointer text-lg">Average only</Label>
</div>
</div>
</div>
<!-- ─── Models Selection ───────────────────────────────────────────────────── -->
<div class="mt-4 md:mt-8">
<div class="flex">
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
{#if params.models && params.models.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
@@ -362,8 +290,7 @@
return item !== value;
});
} else if (params.models) {
params.models.push(value);
params.models = params.models;
params.models = [...params.models, value];
}
}}
/>
@@ -378,7 +305,8 @@
{/each}
</div>
<!-- HOURLY -->
<!-- ─── Hourly Variables Selection ─────────────────────────────────────── -->
<div class="mt-6 md:mt-12">
<div class="flex">
<a href="#hourly_weather_variables"
@@ -387,7 +315,7 @@
</h2></a
>
{#if params.hourly && params.hourly.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
@@ -416,8 +344,7 @@
return item !== value;
});
} else if (params.hourly) {
params.hourly.push(value);
params.hourly = params.hourly;
params.hourly = [...params.hourly, value];
}
}}
/>
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
// Default configuration for weather comparison charts
export const defaultParameters = {
timeformat: 'iso8601',
wind_speed_unit: 'kmh',
temperature_unit: 'celsius',
precipitation_unit: 'mm'
};
-2
View File
@@ -8,8 +8,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo';
import type { PageLoad } from './$types';
export const prerender = true;
export const load = (async () => {
const location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name);
@@ -6,8 +6,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo';
import type { PageLoad } from './$types';
export const prerender = true;
export const load: PageLoad = async (event) => {
const urlLocation = event.params.location;
let urlLocationSplit, urlLocationName, urlLocationId;