357 lines
11 KiB
Svelte
357 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import { fade } from 'svelte/transition';
|
|
|
|
import { type GeoLocation, 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 { Checkbox } from '$lib/components/ui/checkbox';
|
|
import { Label } from '$lib/components/ui/label';
|
|
import { Switch } from '$lib/components/ui/switch';
|
|
|
|
import {
|
|
type MarkArea,
|
|
type ModelCompareResult,
|
|
fetchModelComparison
|
|
} from '$lib/services/weather';
|
|
|
|
import { hourly, models as modelsFlat } from '../../options';
|
|
import { defaultParameters } from '../../options';
|
|
|
|
import type * as echarts from 'echarts';
|
|
|
|
const models = [modelsFlat];
|
|
|
|
// ─── 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);
|
|
|
|
let location = $state<GeoLocation>($storedLocation);
|
|
storedLocation.subscribe((value) => {
|
|
location = value;
|
|
});
|
|
|
|
let params = $state({
|
|
...defaultParameters,
|
|
hourly: [
|
|
'temperature_2m',
|
|
'rain',
|
|
'relative_humidity_2m',
|
|
'wind_speed_10m',
|
|
'wind_direction_10m'
|
|
],
|
|
models: [
|
|
'ecmwf_ifs',
|
|
'ecmwf_ifs025',
|
|
'meteofrance_seamless',
|
|
'ukmo_seamless',
|
|
'icon_seamless',
|
|
'gem_seamless',
|
|
'gfs_seamless'
|
|
]
|
|
});
|
|
|
|
// ─── Cached API Response ────────────────────────────────────────────────────
|
|
|
|
interface FetchedData {
|
|
hourly: Record<string, unknown>;
|
|
hourly_units: Record<string, string>;
|
|
timezone: string;
|
|
markAreas: MarkArea[];
|
|
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 loc = location;
|
|
|
|
const loadData = async () => {
|
|
loading = true;
|
|
chartInstances = [];
|
|
chartComponents = [];
|
|
|
|
const result: ModelCompareResult = await fetchModelComparison({
|
|
latitude: loc.latitude!,
|
|
longitude: loc.longitude!,
|
|
hourlyVariables: hourlyVars,
|
|
models: modelList,
|
|
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',
|
|
timezone: loc.timezone
|
|
});
|
|
|
|
fetchedData = {
|
|
hourly: result.hourlyFlat,
|
|
hourly_units: result.hourlyUnitsFlat,
|
|
timezone: result.timezone,
|
|
markAreas: result.markAreas,
|
|
timestamps: result.timestamps
|
|
};
|
|
|
|
loading = false;
|
|
};
|
|
|
|
loadData();
|
|
});
|
|
|
|
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
|
|
|
$effect(() => {
|
|
if (!fetchedData) return;
|
|
|
|
const { hourly: hourlyData, hourly_units, timezone, markAreas, timestamps } = fetchedData;
|
|
const _showLegend = showLegend;
|
|
|
|
const colors = getThemeColors();
|
|
const variableCount = params.hourly?.length || 0;
|
|
const timeLength = timestamps.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());
|
|
|
|
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, timezone },
|
|
legend: { show: _showLegend },
|
|
grid: {
|
|
hasTitle: isFirst,
|
|
hasSubtitle: isFirst,
|
|
showLegend: _showLegend
|
|
},
|
|
yAxis: { unit },
|
|
series,
|
|
toolbox: false,
|
|
showCredit: isLast,
|
|
colors,
|
|
timezone
|
|
});
|
|
|
|
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="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>
|
|
|
|
<!-- ─── 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-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"
|
|
>
|
|
{params.models?.length || 0} / {models.flat().length}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-2 grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
|
{#each models as group, i (i)}
|
|
<div class="mb-3">
|
|
{#each group as item (item.value)}
|
|
{@const { value, label } = item as { value: string; label: string }}
|
|
<div class="group flex items-center" title={label}>
|
|
<Checkbox
|
|
id="{value}_model"
|
|
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
|
|
{value}
|
|
checked={params.models?.includes(value)}
|
|
aria-labelledby="{value}_label"
|
|
onCheckedChange={() => {
|
|
if (params.models?.includes(value)) {
|
|
params.models = params.models.filter((item) => {
|
|
return item !== value;
|
|
});
|
|
} else if (params.models) {
|
|
params.models = [...params.models, value];
|
|
}
|
|
}}
|
|
/>
|
|
<Label
|
|
id="{value}_model_label"
|
|
for="{value}_model"
|
|
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
|
|
>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- ─── Hourly Variables Selection ─────────────────────────────────────── -->
|
|
|
|
<div class="mt-6 md:mt-12">
|
|
<div class="flex">
|
|
<a href="#hourly_weather_variables"
|
|
><h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
|
|
Hourly Weather Variables
|
|
</h2></a
|
|
>
|
|
{#if params.hourly && params.hourly.length > 0}
|
|
<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"
|
|
>
|
|
{params.hourly?.length || 0} / {hourly.flat().length}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div
|
|
class="mt-2 grid grid-flow-row gap-x-2 gap-y-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
|
|
>
|
|
{#each hourly as group, i (i)}
|
|
<div>
|
|
{#each group as { value, label } (value)}
|
|
<div class="group flex items-center" title={label}>
|
|
<Checkbox
|
|
id="{value}_hourly"
|
|
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
|
|
{value}
|
|
checked={params.hourly?.includes(value)}
|
|
aria-labelledby="{value}_label"
|
|
onCheckedChange={() => {
|
|
if (params.hourly?.includes(value)) {
|
|
params.hourly = params.hourly.filter((item) => {
|
|
return item !== value;
|
|
});
|
|
} else if (params.hourly) {
|
|
params.hourly = [...params.hourly, value];
|
|
}
|
|
}}
|
|
/>
|
|
<Label
|
|
id="{value}_label"
|
|
for="{value}_hourly"
|
|
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
|
|
>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|