Co-authored-by: terraputix <terraputix@mailbox.org> Reviewed-on: useweb/open-meteo-blue#5
221 lines
6.5 KiB
Svelte
221 lines
6.5 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import { get } from 'svelte/store';
|
|
|
|
import { storedLocation } from '$lib/stores/settings';
|
|
|
|
import {
|
|
buildAverageSeries,
|
|
buildCurrentTimeSeries,
|
|
buildDaylightSeries,
|
|
buildSpreadSeries,
|
|
composeChartOption,
|
|
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 {
|
|
type EnsembleForecastResult,
|
|
type MarkArea,
|
|
fetchEnsembleForecast
|
|
} from '$lib/services/weather';
|
|
|
|
import { defaultParameters } from '../options';
|
|
|
|
import type * as echarts from 'echarts';
|
|
|
|
// ─── 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 mounted = $state(false);
|
|
let loading = $state(true);
|
|
|
|
const location = get(storedLocation);
|
|
|
|
let params = $state({
|
|
latitude: [52.52],
|
|
longitude: [13.41],
|
|
...defaultParameters,
|
|
hourly: ['temperature_2m'],
|
|
models: ['gfs_seamless']
|
|
});
|
|
|
|
// ─── Cached API Response ────────────────────────────────────────────────────
|
|
|
|
interface FetchedData {
|
|
ensembleResult: EnsembleForecastResult;
|
|
timestamps: number[];
|
|
utc_offset_seconds: number;
|
|
markAreas: MarkArea[];
|
|
}
|
|
|
|
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 () => {
|
|
loading = true;
|
|
chartInstances = [];
|
|
chartComponents = [];
|
|
|
|
const result: EnsembleForecastResult = await fetchEnsembleForecast({
|
|
latitude: location.latitude!,
|
|
longitude: location.longitude!,
|
|
hourlyVariables: hourlyVars,
|
|
models: modelList,
|
|
forecast_days: 14,
|
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
|
|
});
|
|
|
|
fetchedData = {
|
|
ensembleResult: result,
|
|
timestamps: result.timestamps,
|
|
utc_offset_seconds: result.utcOffsetSeconds,
|
|
markAreas: result.markAreas
|
|
};
|
|
|
|
loading = false;
|
|
};
|
|
|
|
loadData();
|
|
});
|
|
|
|
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
|
|
|
$effect(() => {
|
|
if (!fetchedData) return;
|
|
|
|
const { ensembleResult, timestamps, utc_offset_seconds, markAreas } = fetchedData;
|
|
const _showLegend = showLegend;
|
|
|
|
const colors = getThemeColors();
|
|
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 varData = ensembleResult.variables[variable];
|
|
if (!varData) continue;
|
|
|
|
const unit = varData.unit;
|
|
const { average, min: minValues, max: maxValues } = varData;
|
|
|
|
const series: Array<Record<string, unknown>> = [];
|
|
|
|
const spreadData: Array<[number, number, number]> = minValues.map(
|
|
(minVal, index) =>
|
|
[timestamps[index], minVal ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
|
);
|
|
|
|
series.push(...buildSpreadSeries({ variable, spreadData }));
|
|
|
|
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 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: _showLegend
|
|
},
|
|
yAxis: { unit },
|
|
series,
|
|
toolbox: false,
|
|
showCredit: isLast,
|
|
colors
|
|
});
|
|
|
|
newOptions.push(option);
|
|
}
|
|
|
|
chartOptions = newOptions;
|
|
});
|
|
</script>
|
|
|
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
|
|
|
<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} />
|
|
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
|
</div>
|
|
{/snippet}
|
|
</ChartToolbar>
|
|
</div>
|