migrate to apache echarts

This commit is contained in:
terraputix
2026-02-15 16:35:54 +01:00
parent 6872cac00e
commit 0a59a1c845
12 changed files with 652 additions and 1505 deletions
+285 -160
View File
@@ -3,19 +3,20 @@
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { dev } from '$app/environment';
import * as echarts from 'echarts';
import { storedLocation } from '$lib/stores/settings';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import '../compare/highcharts.css';
import '../compare/echarts.css';
import { defaultParameters } from './options';
let node: HTMLElement;
let chart: any;
let Highcharts = $state<typeof import('highcharts') | null>(null);
let charts: echarts.ECharts[] = [];
let resizeObservers: ResizeObserver[] = [];
let mounted = $state(false);
let showLegend = $state(false);
let averageOnly = $state(false);
@@ -32,32 +33,48 @@
});
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);
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);
}
}
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)';
}
onMount(() => {
mounted = true;
});
$effect(() => {
const loadData = async () => {
count = 0;
if (Highcharts) {
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(
@@ -70,41 +87,60 @@
);
const data = await dataReq.json();
let plotBands: any = [];
// 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;
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
};
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';
let unit;
// Append to DOM BEFORE echarts.init so it can measure dimensions
// eslint-disable-next-line svelte/no-dom-manipulating
node.appendChild(chartDiv);
let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
let unit: string = '';
const series = [];
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 any[]).entries()) {
if (val) {
for (let [index, val] of (values as number[]).entries()) {
if (val !== null && val !== undefined) {
let avVal = average[index];
average[index] = avVal + val;
averageCount[index]++;
@@ -122,145 +158,231 @@
}
}
// Calculate average
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]]);
}
// 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: 'temperature_2m_spread',
data: minMax,
type: 'arearange',
tooltip: {
valueSuffix: ' ' + unit
name: variable + '_spread',
type: 'line',
data: spreadData.map((d) => [d[0], d[1]]),
areaStyle: {
color: 'rgba(173, 216, 230, 0.3)',
origin: 'auto'
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-spread-series'
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',
data: average,
dashStyle: 'ShortDashDot',
color: '#5e5e5e',
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
type: isColumn ? 'bar' : 'line',
data: averageData,
smooth: !isColumn,
showSymbol: false,
lineStyle: {
type: 'dashed',
width: 4,
color: '#5e5e5e'
},
lineWidth: 4,
states: {
hover: {
lineWidth: 6
itemStyle: {
color: '#5e5e5e'
},
emphasis: {
lineStyle: {
width: 6
}
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-average-series'
barMaxWidth: 5,
z: 10
});
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: [
// Add current time markLine via a helper series
series.push({
name: 'Current Time',
type: 'line',
data: [],
markLine: {
silent: true,
symbol: 'none',
data: [
{
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
xAxis: Date.now() + data.utc_offset_seconds * 1000,
lineStyle: {
color: 'red',
width: 2
},
label: {
show: false
}
}
]
},
tooltip: {
shared: true,
animation: 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++;
node.appendChild(chartDiv);
}
}
};
@@ -268,22 +390,25 @@
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
resizeObservers.forEach((ro) => ro.disconnect());
resizeObservers = [];
charts.forEach((chart) => {
chart.dispose();
});
charts = [];
});
</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]"
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/100"
: '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"
@@ -314,7 +439,7 @@
params.hourly = params.hourly;
}}
/>
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
</div>
<div class="flex gap-2">
<Switch
@@ -325,7 +450,7 @@
params.hourly = params.hourly;
}}
/>
<Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label>
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
</div>
</div>
</div>