remove echarts, use canvas

This commit is contained in:
Vincent van der Wal
2026-07-19 15:25:04 +02:00
parent 6d81af8df5
commit e031716ce6
37 changed files with 1483 additions and 2510 deletions
@@ -1,28 +1,25 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { storedLocation } from '$lib/stores/settings';
import {
buildAverageSeries,
buildCurrentTimeSeries,
buildDaylightSeries,
buildModelSeries,
calculateAverage,
composeChartOption,
findUnit,
getThemeColors
} from '$lib/utils/echarts';
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
import '$lib/components/charts/echarts.css';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import {
type MarkArea,
CHART_COLORS,
CanvasChart,
type ChartSeries,
SERIES_COLORS,
calculateAverage,
findUnit,
isColumnUnit
} from '$lib/charts';
import {
type DaylightBand,
type ModelCompareResult,
fetchModelComparison
} from '$lib/services/weather';
@@ -32,19 +29,18 @@
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import type { PageData } from './$types';
import type * as echarts from 'echarts';
const models = [modelsFlat];
const CHART_GROUP = 'model-compare';
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]);
let chartComponents: CanvasChart[] = $state([]);
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
@@ -86,7 +82,7 @@
hourly: Record<string, unknown>;
hourly_units: Record<string, string>;
timezone: string;
markAreas: MarkArea[];
daylightBands: DaylightBand[];
timestamps: number[];
sunrise: number[];
sunset: number[];
@@ -100,22 +96,8 @@
mounted = true;
});
onDestroy(() => {
chartInstances = [];
chartOptions = [];
chartComponents = [];
});
// ─── Chart Instance Tracking ────────────────────────────────────────────────
function handleChartReady(chart: echarts.ECharts): void {
chartInstances = [...chartInstances, chart];
}
// chart components persist across refetches (options update in place), so
// the registry is never reset; only instances disposed because the chart
// count shrank are filtered out
let liveCharts = $derived(chartInstances.filter((chart) => !chart.isDisposed()));
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
// ─── Data Fetching (only when params.hourly or params.models change) ───────
@@ -149,7 +131,7 @@
hourly: result.hourlyFlat,
hourly_units: result.hourlyUnitsFlat,
timezone: result.timezone,
markAreas: result.markAreas,
daylightBands: result.daylightBands,
timestamps: result.timestamps,
sunrise: result.sunrise,
sunset: result.sunset
@@ -164,83 +146,77 @@
});
});
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
$effect(() => {
if (!fetchedData) return;
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
let timestampsSec = $derived.by(() =>
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
);
const { hourly: hourlyData, hourly_units, timezone, markAreas, timestamps } = fetchedData;
const _showLegend = showLegend;
interface ChartDef {
title?: string;
subtitle?: string;
unit: string;
showCredit: boolean;
series: ChartSeries[];
}
const colors = getThemeColors();
let chartDefs = $derived.by((): ChartDef[] => {
if (!fetchedData) return [];
const { hourly: hourlyData, hourly_units, timestamps } = fetchedData;
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
const variableCount = chartVariables.length;
const timeLength = timestamps.length;
const newOptions: Array<Record<string, unknown>> = [];
const defs: ChartDef[] = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = chartVariables[vi];
const unit = findUnit(hourly_units, hourlyData, variable);
const isColumn = isColumnUnit(unit);
const series: Array<Record<string, unknown>> = [];
const series: ChartSeries[] = [];
let modelIndex = 0;
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
})
);
series.push({
name: model,
type: isColumn ? 'bar' : 'line',
color: SERIES_COLORS[modelIndex % SERIES_COLORS.length],
data: values as (number | null)[],
width: 2
});
modelIndex++;
}
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());
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
series.push({
name: variable + '_average',
type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average,
data: average,
width: 4,
dashed: !isColumn
});
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
const option = composeChartOption({
title: isFirst
? {
text: 'Model Compare',
subtext: `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit, timezone },
legend: { show: _showLegend },
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend: _showLegend
},
yAxis: { unit },
series,
toolbox: false,
defs.push({
title: isFirst ? 'Model Compare' : undefined,
subtitle: isFirst
? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
: undefined,
unit,
showCredit: isLast,
colors,
timezone
series
});
newOptions.push(option);
}
chartOptions = newOptions;
return defs;
});
</script>
@@ -254,16 +230,25 @@
</div>
{/if}
<ChartContainer {loading} chartCount={chartOptions.length} chartHeight={showLegend ? 400 : 300}>
{#each chartOptions as option, i (i)}
<EChart
{option}
notMerge
height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady}
bind:this={chartComponents[i]}
/>
{/each}
<ChartContainer {loading} chartCount={chartDefs.length || 1} chartHeight={300}>
{#if fetchedData}
{#each chartDefs as def, i (i)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
title={def.title}
subtitle={def.subtitle}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
{/each}
{/if}
</ChartContainer>
{#if fetchedData && !loading}