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
+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];
}
}}
/>