-
-
- {
- params.hourly = params.hourly;
- }}
- />
-
-
-
- {
- params.hourly = params.hourly;
- }}
- />
-
-
-
+
+ {#each chartOptions as option, i (i)}
+
+ {/each}
+
+
+
+
+
+
+ {#snippet controls()}
+
+ {
+ params.hourly = params.hourly;
+ }}
+ />
+
+
+
+ {
+ params.hourly = params.hourly;
+ }}
+ />
+
+
+ {/snippet}
+
diff --git a/src/routes/weather/14-day/options.ts b/src/routes/weather/14-day/options.ts
deleted file mode 100644
index 970b98d..0000000
--- a/src/routes/weather/14-day/options.ts
+++ /dev/null
@@ -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'
-};
diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte
index a8ea7cd..6875688 100644
--- a/src/routes/weather/compare/+page.svelte
+++ b/src/routes/weather/compare/+page.svelte
@@ -3,25 +3,42 @@
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
- import * as echarts from 'echarts';
-
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 './echarts.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 charts: echarts.ECharts[] = [];
- let resizeObservers: ResizeObserver[] = [];
+ // ─── State ──────────────────────────────────────────────────────────────────
+
+ let chartComponents: EChart[] = $state([]);
+ let chartInstances: echarts.ECharts[] = $state([]);
+ let chartOptions: Array
> = $state([]);
let mounted = $state(false);
+ let loading = $state(true);
let showLegend = $state(false);
let averageOnly = $state(false);
@@ -42,402 +59,203 @@
]
});
- let count = $state(0);
-
- function isDarkMode(): boolean {
- if (typeof document === 'undefined') return false;
- return (
- document.documentElement.classList.contains('dark') ||
- document.documentElement.getAttribute('data-theme') === 'dark' ||
- (window.matchMedia &&
- window.matchMedia('(prefers-color-scheme: dark)').matches &&
- document.documentElement.getAttribute('data-theme') !== 'light')
- );
- }
-
- function getTextColor(): string {
- return isDarkMode() ? '#e5e7eb' : '#374151';
- }
-
- function getAxisLineColor(): string {
- return isDarkMode() ? 'rgba(229, 231, 235, 0.3)' : 'rgba(55, 65, 81, 0.3)';
- }
-
- function getSplitLineColor(): string {
- return isDarkMode() ? 'rgba(229, 231, 235, 0.1)' : 'rgba(55, 65, 81, 0.1)';
- }
+ // ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
mounted = true;
});
+ onDestroy(() => {
+ chartInstances = [];
+ chartOptions = [];
+ chartComponents = [];
+ });
+
+ // ─── Chart Instance Tracking ────────────────────────────────────────────────
+
+ function handleChartReady(chart: echarts.ECharts): void {
+ chartInstances = [...chartInstances, chart];
+ }
+
+ // ─── Data Loading & Chart Building ──────────────────────────────────────────
+
$effect(() => {
const loadData = async () => {
- count = 0;
- if (mounted) {
- // Dispose existing charts and observers
- resizeObservers.forEach((ro) => ro.disconnect());
- resizeObservers = [];
- charts.forEach((chart) => {
- if (chart) {
- chart.dispose();
- }
- });
- charts = [];
- // eslint-disable-next-line svelte/no-dom-manipulating
- node.replaceChildren();
+ if (!mounted) return;
- 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();
+ 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();
+
+ // ─── Compute daylight bands ─────────────────────────────────────
+
+ let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
+ [];
+
+ if ('daily' in data) {
+ // Find the first model-suffixed key for sunrise/sunset
let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
dailyFirstModelKey.shift();
dailyFirstModelKey = dailyFirstModelKey.join('_');
- // Create day/night plot bands as markArea data
- let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
- [];
- 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];
- markAreas = rise.map(function (r: number, i: number) {
- return [
- {
- xAxis: (r + data.utc_offset_seconds) * 1000,
- itemStyle: {
- color: 'rgba(255, 255, 194, 0.3)'
- }
- },
- {
- xAxis: (set[i] + data.utc_offset_seconds) * 1000
- }
- ];
- });
- }
+ const sunriseKey = 'sunrise_' + dailyFirstModelKey;
+ const sunsetKey = 'sunset_' + dailyFirstModelKey;
- const textColor = getTextColor();
- const axisLineColor = getAxisLineColor();
- const splitLineColor = getSplitLineColor();
-
- for (let variable of params.hourly || []) {
- const chartDiv = document.createElement('div');
- chartDiv.style.width = '100%';
- chartDiv.style.height = showLegend ? '400px' : '300px';
-
- // Append to DOM BEFORE echarts.init so it can measure dimensions
- // eslint-disable-next-line svelte/no-dom-manipulating
- node.appendChild(chartDiv);
-
- let unit: string = '';
-
- const series: Array> = [];
- let average = new Array(data.hourly.time.length).fill(0);
- let averageCount = new Array(data.hourly.time.length).fill(0);
-
- const timestamps = data.hourly.time.map(
- (t: number) => (t + data.utc_offset_seconds) * 1000
+ if (sunriseKey in data.daily && sunsetKey in data.daily) {
+ markAreas = buildDaylightMarkAreas(
+ data.daily[sunriseKey],
+ data.daily[sunsetKey],
+ data.utc_offset_seconds
);
-
- for (let [model, values] of Object.entries(data.hourly)) {
- if (model === 'time') {
- continue;
- }
- if (model.startsWith(variable)) {
- for (let [index, val] of (values as number[]).entries()) {
- if (val !== null && val !== undefined) {
- let avVal = average[index];
- average[index] = avVal + val;
- averageCount[index]++;
- }
- }
-
- unit = data.hourly_units[model];
-
- if (!averageOnly) {
- const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²';
-
- const seriesData = (values as (number | null)[]).map(
- (val: number | null, idx: number) => [timestamps[idx], val]
- );
-
- series.push({
- name: model,
- type: isColumn ? 'bar' : 'line',
- data: seriesData,
- smooth: !isColumn,
- showSymbol: false,
- lineStyle: {
- width: 2
- },
- emphasis: {
- lineStyle: {
- width: 3
- }
- },
- barMaxWidth: 5
- });
- }
- }
- }
-
- // Calculate average
- for (let [index, val] of average.entries()) {
- average[index] = Math.round((val / averageCount[index]) * 10) / 10;
- }
-
- const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²';
- const averageData = average.map((val: number, idx: number) => [timestamps[idx], val]);
-
- series.push({
- name: variable + '_average',
- type: isColumn ? 'bar' : 'line',
- data: averageData,
- smooth: !isColumn,
- showSymbol: false,
- lineStyle: {
- type: 'dashed',
- width: 4,
- color: '#5e5e5e'
- },
- itemStyle: {
- color: '#5e5e5e'
- },
- emphasis: {
- lineStyle: {
- width: 6
- }
- },
- barMaxWidth: 5,
- z: 10
- });
-
- // Add current time markLine via a helper series
- series.push({
- name: 'Current Time',
- type: 'line',
- data: [],
- markLine: {
- silent: true,
- symbol: 'none',
- data: [
- {
- xAxis: Date.now() + data.utc_offset_seconds * 1000,
- lineStyle: {
- color: 'red',
- width: 2
- },
- label: {
- show: false
- }
- }
- ]
- }
- });
-
- // Add day/night bands via markArea
- if (markAreas.length > 0) {
- series.push({
- name: 'Daylight',
- type: 'line',
- data: [],
- markArea: {
- silent: true,
- data: markAreas
- }
- });
- }
-
- const option: Record = {
- title: {
- text: count === 0 ? 'Model Compare' : '',
- left: 'left',
- textStyle: {
- fontWeight: 'normal',
- color: textColor
- },
- ...(count === 0
- ? {
- subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`,
- subtextStyle: {
- fontWeight: 'normal',
- color: textColor
- }
- }
- : {})
- },
- tooltip: {
- trigger: 'axis',
- axisPointer: {
- type: 'cross',
- animation: false
- },
- valueFormatter: (value: number) => {
- if (value === null || value === undefined) return '-';
- return value.toFixed(1) + ' ' + unit;
- }
- },
- legend: {
- show: showLegend,
- bottom: 0,
- type: 'scroll',
- textStyle: {
- color: textColor
- }
- },
- grid: {
- left: 60,
- right: 10,
- top: count === 0 ? 80 : 40,
- bottom: showLegend ? 60 : 40
- },
- xAxis: {
- type: 'time',
- splitLine: {
- show: false
- },
- axisLine: {
- lineStyle: {
- color: axisLineColor
- }
- },
- axisLabel: {
- color: textColor
- }
- },
- yAxis: {
- type: 'value',
- name: unit,
- nameTextStyle: {
- color: textColor
- },
- axisLine: {
- show: false
- },
- axisLabel: {
- color: textColor
- },
- splitLine: {
- lineStyle: {
- color: splitLineColor
- }
- }
- },
- series: series,
- textStyle: {
- color: textColor
- }
- };
-
- // Add credits for last chart
- if (count === (params.hourly?.length || 0) - 1) {
- option.graphic = [
- {
- type: 'text',
- right: 10,
- bottom: 5,
- style: {
- text: 'Open-Meteo.com',
- fontSize: 10,
- fill: textColor,
- opacity: 0.5
- },
- onclick: function () {
- window.open('https://open-meteo.com', '_blank');
- },
- cursor: 'pointer'
- }
- ];
- }
-
- const chart = echarts.init(chartDiv, null, { renderer: 'canvas' });
- charts.push(chart);
- chart.setOption(option);
-
- // Handle responsive resize
- const resizeObserver = new ResizeObserver(() => {
- chart.resize();
- });
- resizeObserver.observe(chartDiv);
- resizeObservers.push(resizeObserver);
-
- count++;
}
}
- };
- loadData();
- });
- onDestroy(() => {
- resizeObservers.forEach((ro) => ro.disconnect());
- resizeObservers = [];
- charts.forEach((chart) => {
- chart.dispose();
- });
- charts = [];
+ // ─── Build chart options for each variable ──────────────────────
+
+ const colors = getThemeColors();
+ const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
+ const variableCount = params.hourly?.length || 0;
+ const newOptions: Array> = [];
+
+ for (let vi = 0; vi < variableCount; vi++) {
+ const variable = params.hourly![vi];
+ const unit = findUnit(data.hourly_units, data.hourly, variable);
+ const timeLength = data.hourly.time.length;
+
+ // ─── Build individual model series ───────────────────────────
+
+ const series: Array> = [];
+
+ if (!averageOnly) {
+ for (const [model, values] of Object.entries(data.hourly)) {
+ if (model === 'time') continue;
+ if (!model.startsWith(variable)) continue;
+
+ const seriesData = (values as (number | null)[]).map(
+ (val, idx) => [timestamps[idx], val] as [number, number | null]
+ );
+
+ series.push(
+ buildModelSeries({
+ name: model,
+ data: seriesData,
+ unit
+ })
+ );
+ }
+ }
+
+ // ─── Average series ─────────────────────────────────────────
+
+ const { average } = calculateAverage(data.hourly, variable, timeLength);
+ const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
+
+ series.push(buildAverageSeries({ variable, data: averageData, unit }));
+
+ // ─── Annotation series ───────────────────────────────────────
+
+ series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
+
+ const daylightSeries = buildDaylightSeries({ markAreas });
+ if (daylightSeries) {
+ series.push(daylightSeries);
+ }
+
+ // ─── Compose final option ───────────────────────────────────
+
+ const isFirst = vi === 0;
+ const isLast = vi === variableCount - 1;
+
+ const option = composeChartOption({
+ title: isFirst
+ ? {
+ text: 'Model Compare',
+ subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
+ }
+ : null,
+ tooltip: { unit },
+ legend: { show: showLegend },
+ grid: {
+ hasTitle: isFirst,
+ hasSubtitle: isFirst,
+ showLegend
+ },
+ yAxis: { unit },
+ series,
+ toolbox: {
+ saveAsImage: true,
+ fileName: `model-compare-${variable}`,
+ format: 'png'
+ },
+ showCredit: isLast,
+ colors
+ });
+
+ newOptions.push(option);
+ }
+
+ chartOptions = newOptions;
+ loading = false;
+ };
+
+ loadData();
});
-
-
+
+
-
-
+ {#each chartOptions as option, i (i)}
+
+ {/each}
+
+
+
+
+
+
+ {#snippet controls()}
+
+ {
+ params.hourly = params.hourly;
+ }}
+ />
+
+
+
+ {
+ params.hourly = params.hourly;
+ }}
+ />
+
+
+ {/snippet}
+
-
-
-
- {
- params.hourly = params.hourly;
- }}
- />
-
-
-
- {
- params.hourly = params.hourly;
- }}
- />
-
-
-
-
+
@@ -487,7 +305,8 @@
{/each}
-
+
+