WIP: refactoring

This commit is contained in:
terraputix
2026-02-15 18:35:18 +01:00
parent 0a59a1c845
commit e12add9ac3
15 changed files with 2203 additions and 877 deletions
+199 -418
View File
@@ -1,22 +1,39 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
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,
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/echarts.css';
import { defaultParameters } from './options';
import { defaultParameters } from '../options';
let node: HTMLElement;
let charts: echarts.ECharts[] = [];
let resizeObservers: ResizeObserver[] = [];
import type * as echarts from 'echarts';
// ─── 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);
let showLegend = $state(false);
let averageOnly = $state(false);
@@ -32,425 +49,189 @@
models: ['gfs_seamless']
});
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;
});
$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();
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();
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();
// Create day/night plot bands as markArea data
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
[];
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
let rise = wd.daily.sunrise;
let set = wd.daily.sunset;
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
}
];
});
}
let minValues = new Array(data.hourly.time.length).fill(undefined);
let maxValues = new Array(data.hourly.time.length).fill(undefined);
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<Record<string, unknown>> = [];
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
);
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]++;
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];
}
}
// Calculate average
for (let [index, val] of average.entries()) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
}
// Create min-max area data
const spreadData: Array<[number, number, number]> = minValues.map(
(min: number, index: number) => [timestamps[index], min, maxValues[index]]
);
const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²';
// Add spread series (lower bound)
series.push({
name: variable + '_spread',
type: 'line',
data: spreadData.map((d) => [d[0], d[1]]),
areaStyle: {
color: 'rgba(173, 216, 230, 0.3)',
origin: 'auto'
},
lineStyle: {
width: 0
},
showSymbol: false,
stack: 'spread',
smooth: true,
z: 1
});
// Add spread series (upper bound delta)
series.push({
name: variable + '_spread_max',
type: 'line',
data: spreadData.map((d) => [d[0], d[2] - d[1]]),
areaStyle: {
color: 'rgba(173, 216, 230, 0.3)',
origin: 'auto'
},
lineStyle: {
width: 0
},
showSymbol: false,
stack: 'spread',
smooth: true,
z: 1
});
// Add average line
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<string, unknown> = {
title: {
text: count === 0 ? 'Model Spread' : '',
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',
data: [variable + '_average'],
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(() => {
chartInstances = [];
chartOptions = [];
chartComponents = [];
});
onDestroy(() => {
resizeObservers.forEach((ro) => ro.disconnect());
resizeObservers = [];
charts.forEach((chart) => {
chart.dispose();
});
charts = [];
// ─── Chart Instance Tracking ────────────────────────────────────────────────
function handleChartReady(chart: echarts.ECharts): void {
chartInstances = [...chartInstances, chart];
}
// ─── Data Loading & Chart Building ──────────────────────────────────────────
$effect(() => {
const loadData = async () => {
if (!mounted) return;
loading = true;
chartInstances = [];
chartComponents = [];
// Fetch sunrise/sunset from the standard forecast API
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();
// Fetch ensemble data
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: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
[];
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
markAreas = buildDaylightMarkAreas(
wd.daily.sunrise,
wd.daily.sunset,
data.utc_offset_seconds
);
}
// ─── 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<Record<string, unknown>> = [];
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;
// ─── Calculate average and spread ───────────────────────────
const { average } = calculateAverage(data.hourly, variable, timeLength);
const { minValues, maxValues } = calculateSpread(data.hourly, variable, timeLength);
// ─── Build series ───────────────────────────────────────────
const series: Array<Record<string, unknown>> = [];
// Ensemble spread (min/max area)
const spreadData: Array<[number, number, number]> = minValues.map(
(min, index) =>
[timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number]
);
series.push(...buildSpreadSeries({ variable, spreadData }));
// Average line
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
// Current time marker
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
// Daylight bands
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
// ─── Compose final option ───────────────────────────────────
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
const option = composeChartOption({
title: isFirst
? {
text: 'Model Spread',
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit },
legend: {
show: showLegend,
data: [variable + '_average']
},
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend
},
yAxis: { unit },
series,
toolbox: {
saveAsImage: true,
fileName: `14-day-forecast-${variable}`,
format: 'png'
},
showCredit: isLast,
colors
});
newOptions.push(option);
}
chartOptions = newOptions;
loading = false;
};
loadData();
});
</script>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div
class="container-wrapper relative -mx-6 md:mx-0"
style="min-height: {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"
>
<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-0.5 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-0.5 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}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="show_legend" class="mb-0.5 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-0.5 cursor-pointer text-lg">Average only</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'
};
+199 -380
View File
@@ -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<Record<string, unknown>> = $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<Record<string, unknown>> = [];
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<string, unknown> = {
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<Record<string, unknown>> = [];
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<Record<string, unknown>> = [];
if (!averageOnly) {
for (const [model, values] of Object.entries(data.hourly)) {
if (model === 'time') continue;
if (!model.startsWith(variable)) continue;
const seriesData = (values as (number | null)[]).map(
(val, idx) => [timestamps[idx], val] as [number, number | null]
);
series.push(
buildModelSeries({
name: model,
data: seriesData,
unit
})
);
}
}
// ─── Average series ─────────────────────────────────────────
const { average } = calculateAverage(data.hourly, variable, timeLength);
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
// ─── Annotation series ───────────────────────────────────────
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
// ─── Compose final option ───────────────────────────────────
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
const option = composeChartOption({
title: isFirst
? {
text: 'Model Compare',
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit },
legend: { show: showLegend },
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend
},
yAxis: { unit },
series,
toolbox: {
saveAsImage: true,
fileName: `model-compare-${variable}`,
format: 'png'
},
showCredit: isLast,
colors
});
newOptions.push(option);
}
chartOptions = newOptions;
loading = false;
};
loadData();
});
</script>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div
class="container-wrapper relative -mx-6 md:mx-0"
style="min-height: {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"
>
<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}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="show_legend" class="mb-0.5 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-0.5 cursor-pointer text-lg">Average only</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-0.5 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-0.5 cursor-pointer text-lg">Average only</Label>
</div>
</div>
</div>
<!-- ─── Models Selection ───────────────────────────────────────────────────── -->
<div class="mt-4 md:mt-8">
<div class="flex">
@@ -487,7 +305,8 @@
{/each}
</div>
<!-- HOURLY -->
<!-- ─── Hourly Variables Selection ─────────────────────────────────────── -->
<div class="mt-6 md:mt-12">
<div class="flex">
<a href="#hourly_weather_variables"
-65
View File
@@ -1,65 +0,0 @@
/* ECharts theme integration for Open-Meteo Weather */
/* Container styling */
.echarts-container {
width: 100%;
height: 100%;
min-height: 300px;
}
/* Ensure ECharts respects current color scheme */
[data-theme='light'] .echarts-container,
:root:not([data-theme='dark']) .echarts-container {
color: hsl(var(--foreground));
}
/* Tooltip styling to match application theme */
.echarts-tooltip {
background: hsl(var(--popover)) !important;
border: 1px solid hsl(var(--border)) !important;
border-radius: var(--radius) !important;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
padding: 0.75rem !important;
}
.echarts-tooltip-content {
color: hsl(var(--popover-foreground)) !important;
}
/* Ensure text is readable in both themes */
.echarts-container text {
fill: currentColor !important;
}
/* Chart background */
.echarts-container canvas {
background: transparent !important;
}
/* Loading state */
.echarts-loading-mask {
background: hsl(var(--background) / 0.8) !important;
}
/* Color palette for series */
:root {
--echarts-color-0: #5470c6;
--echarts-color-1: #91cc75;
--echarts-color-2: #fac858;
--echarts-color-3: #ee6666;
--echarts-color-4: #73c0de;
--echarts-color-5: #3ba272;
--echarts-color-6: #fc8452;
--echarts-color-7: #9a60b4;
--echarts-color-8: #ea7ccc;
--echarts-color-9: #5470c6;
}
/* Responsive sizing */
@media (max-width: 768px) {
.echarts-container {
min-height: 250px;
}
}
-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'
};