544 lines
18 KiB
Svelte
544 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { get } from 'svelte/store';
|
|
import { fade } from 'svelte/transition';
|
|
|
|
import { page } from '$app/stores';
|
|
|
|
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
|
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
|
|
|
import { formatZoned } from '$lib/utils/date';
|
|
import { readList, syncSearchParams } from '$lib/utils/url-state';
|
|
|
|
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 {
|
|
CHART_COLORS,
|
|
CanvasChart,
|
|
type ChartSeries,
|
|
SERIES_COLORS,
|
|
calculateAverage,
|
|
findUnit,
|
|
groupRange,
|
|
isColumnUnit
|
|
} from '$lib/charts';
|
|
import * as m from '$lib/paraglide/messages';
|
|
import {
|
|
type DaylightBand,
|
|
type ModelCompareResult,
|
|
fetchModelComparison
|
|
} from '$lib/services/weather';
|
|
|
|
import { useHeroActions } from '../../hero.svelte';
|
|
import { findModel, hourly, modelGroups } from '../../options';
|
|
import { defaultParameters } from '../../options';
|
|
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
|
|
|
import type { PageData } from './$types';
|
|
|
|
const models = modelGroups.map((group) => group.models);
|
|
|
|
const CHART_GROUP = 'model-compare';
|
|
|
|
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
|
|
|
let showLegend = $state(false);
|
|
|
|
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
|
|
|
let chartComponents: CanvasChart[] = $state([]);
|
|
let mounted = $state(false);
|
|
let loading = $state(true);
|
|
let loadError = $state<string | null>(null);
|
|
let requestVersion = 0;
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
// the page cross-fade waits for this before revealing the new page
|
|
reportPageReady(() => fetchedData != null);
|
|
|
|
useHeroActions(heroActions);
|
|
|
|
// the URL is the source of truth: location comes from the load function,
|
|
// which is also correct on hydrated prerendered pages. The persisted store
|
|
// only mirrors it so the header and bare /weather/* redirects follow along.
|
|
let location = $derived(data.location);
|
|
$effect(() => {
|
|
storedLocation.set(data.location);
|
|
});
|
|
|
|
let params = $state({
|
|
...defaultParameters,
|
|
hourly: ['temperature_2m', 'rain', 'wind_speed_10m'],
|
|
models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless']
|
|
});
|
|
|
|
// units live in a persisted store; mirror them into params so a change
|
|
// re-runs the fetch effect (which reads params.*_unit)
|
|
$effect(() => {
|
|
params.temperature_unit = $storedUnits.temperature_unit;
|
|
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
|
params.precipitation_unit = $storedUnits.precipitation_unit;
|
|
});
|
|
|
|
// ─── Cached API Response ────────────────────────────────────────────────────
|
|
|
|
interface FetchedData {
|
|
hourly: Record<string, unknown>;
|
|
hourly_units: Record<string, string>;
|
|
timezone: string;
|
|
daylightBands: DaylightBand[];
|
|
timestamps: number[];
|
|
sunrise: number[];
|
|
sunset: number[];
|
|
}
|
|
|
|
let fetchedData: FetchedData | null = $state(null);
|
|
|
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
|
|
onMount(() => {
|
|
// A link carries the exact comparison it was shared with; without one, the
|
|
// model chosen elsewhere on the site joins the default line-up.
|
|
const url = get(page).url;
|
|
const urlModels = readList(url, 'models');
|
|
const urlVars = readList(url, 'vars');
|
|
|
|
if (urlModels) params.models = urlModels;
|
|
else {
|
|
const selectedModel = get(storedModel);
|
|
if (selectedModel !== 'best_match' && !params.models.includes(selectedModel)) {
|
|
params.models = [selectedModel, ...params.models];
|
|
}
|
|
}
|
|
if (urlVars) params.hourly = urlVars;
|
|
mounted = true;
|
|
});
|
|
|
|
// Mirror the comparison back into the URL so it can be shared or reloaded.
|
|
$effect(() => {
|
|
const models = params.models;
|
|
const vars = params.hourly;
|
|
if (!mounted) return;
|
|
syncSearchParams(get(page).url, {
|
|
models: models?.length ? models.join(',') : null,
|
|
vars: vars?.length ? vars.join(',') : null
|
|
});
|
|
});
|
|
|
|
// components persist across refetches; entries are null while unmounted
|
|
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
|
|
|
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
|
|
|
|
const SECONDS_PER_DAY = 24 * 3600;
|
|
|
|
function dayStartSec(day: Date): number | null {
|
|
if (!fetchedData) return null;
|
|
const tz = fetchedData.timezone;
|
|
const target = formatZoned(day, tz, 'yyyy-MM-dd');
|
|
const idx = fetchedData.timestamps.findIndex(
|
|
(t) => formatZoned(new Date(t), tz, 'yyyy-MM-dd') === target
|
|
);
|
|
return idx === -1 ? null : fetchedData.timestamps[idx] / 1000;
|
|
}
|
|
|
|
function setRangeDays(from: Date, days: number): void {
|
|
const start = dayStartSec(from);
|
|
if (start == null || liveCharts.length === 0) return;
|
|
// charts share the group, so setting the range on one syncs all of them
|
|
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
|
|
}
|
|
|
|
function resetZoom(): void {
|
|
liveCharts[0]?.resetRange();
|
|
}
|
|
|
|
const rangePresets = [
|
|
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
|
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
|
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
|
{ label: 'All', apply: () => resetZoom() }
|
|
];
|
|
|
|
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
|
|
|
|
// ─── 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;
|
|
|
|
// versioned so a slow stale response can never overwrite a newer one
|
|
const version = ++requestVersion;
|
|
loading = true;
|
|
loadError = null;
|
|
|
|
fetchModelComparison({
|
|
latitude: loc.latitude!,
|
|
longitude: loc.longitude!,
|
|
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
|
|
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
|
|
})
|
|
.then((result: ModelCompareResult) => {
|
|
if (version !== requestVersion) return;
|
|
|
|
fetchedData = {
|
|
hourly: result.hourlyFlat,
|
|
hourly_units: result.hourlyUnitsFlat,
|
|
timezone: result.timezone,
|
|
daylightBands: result.daylightBands,
|
|
timestamps: result.timestamps,
|
|
sunrise: result.sunrise,
|
|
sunset: result.sunset
|
|
};
|
|
|
|
loading = false;
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (version !== requestVersion) return;
|
|
loadError = err instanceof Error ? err.message : String(err);
|
|
loading = false;
|
|
});
|
|
});
|
|
|
|
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
|
|
|
|
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
|
let timestampsSec = $derived.by(() =>
|
|
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
|
|
);
|
|
|
|
interface ChartDef {
|
|
title?: string;
|
|
subtitle?: string;
|
|
unit: string;
|
|
showCredit: boolean;
|
|
series: ChartSeries[];
|
|
}
|
|
|
|
// Human labels for the compared variables (API names → readable title)
|
|
const VAR_LABELS: Record<string, string> = {
|
|
temperature_2m: 'Temperature',
|
|
apparent_temperature: 'Feels like',
|
|
dew_point_2m: 'Dew point',
|
|
precipitation: 'Precipitation',
|
|
rain: 'Rain',
|
|
showers: 'Showers',
|
|
snowfall: 'Snowfall',
|
|
wind_speed_10m: 'Wind speed',
|
|
wind_gusts_10m: 'Wind gusts',
|
|
relative_humidity_2m: 'Relative humidity',
|
|
cloud_cover: 'Cloud cover',
|
|
pressure_msl: 'Pressure (MSL)'
|
|
};
|
|
const varLabel = (v: string): string =>
|
|
VAR_LABELS[v] ?? v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
|
|
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 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: ChartSeries[] = [];
|
|
|
|
let modelIndex = 0;
|
|
for (const [model, values] of Object.entries(hourlyData)) {
|
|
if (model === 'time') continue;
|
|
if (!model.startsWith(variable)) continue;
|
|
|
|
// strip the variable prefix and use the concise model label so the
|
|
// tooltip/legend stay readable (model ids are very long)
|
|
const modelId = model.slice(variable.length + 1);
|
|
series.push({
|
|
name: findModel(modelId)?.label ?? modelId,
|
|
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);
|
|
series.push({
|
|
name: 'Average',
|
|
type: isColumn ? 'bar' : 'line',
|
|
color: CHART_COLORS.average,
|
|
data: average,
|
|
width: 4,
|
|
dashed: !isColumn,
|
|
outline: !isColumn
|
|
});
|
|
|
|
const isFirst = vi === 0;
|
|
const isLast = vi === variableCount - 1;
|
|
|
|
defs.push({
|
|
// label every chart with its variable so each is identifiable
|
|
title: varLabel(variable),
|
|
subtitle: isFirst
|
|
? `${params.models?.length ?? 0} models · dashed = average`
|
|
: `across ${params.models?.length ?? 0} models`,
|
|
unit,
|
|
showCredit: isLast,
|
|
series
|
|
});
|
|
}
|
|
|
|
return defs;
|
|
});
|
|
</script>
|
|
|
|
<!-- the zoom controls ride in the layout's location row (see weather/+layout) -->
|
|
{#snippet heroActions()}
|
|
<!-- Range / zoom controls, aligned with the title like the other pages -->
|
|
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
|
|
<span class="hidden text-xs text-muted-foreground lg:inline">
|
|
{m.meteograms_zoom_hint()}
|
|
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
|
|
>
|
|
{m.meteograms_zoom_hint_end()}
|
|
</span>
|
|
{#if zoomActive}
|
|
<button
|
|
type="button"
|
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
|
onclick={resetZoom}
|
|
>
|
|
<svg
|
|
class="h-3.5 w-3.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
|
|
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
|
|
</svg>
|
|
{m.reset_zoom()}
|
|
</button>
|
|
{/if}
|
|
<div
|
|
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
|
role="group"
|
|
aria-label={m.range_group_aria()}
|
|
>
|
|
{#each rangePresets as preset (preset.label)}
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
|
onclick={preset.apply}
|
|
>
|
|
{preset.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
|
|
|
{#if loadError}
|
|
<div
|
|
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
|
>
|
|
Failed to load weather data: {loadError}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- chart count derives from the selected variables (not the fetched data),
|
|
so the reserved height is right even before the response arrives -->
|
|
{#if fetchedData}
|
|
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
|
the page margins (padded), the graphs bleed to the edges -->
|
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
|
{#each chartDefs as def, i (i)}
|
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
|
<div class="mb-1 px-3 lg:px-0">
|
|
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
|
{#if def.subtitle}
|
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
|
{/if}
|
|
</div>
|
|
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
|
<CanvasChart
|
|
bind:this={chartComponents[i]}
|
|
timestamps={timestampsSec}
|
|
timezone={fetchedData.timezone}
|
|
series={def.series}
|
|
bands={fetchedData.daylightBands}
|
|
unit={def.unit}
|
|
showCredit={def.showCredit}
|
|
{showLegend}
|
|
height={300}
|
|
group={CHART_GROUP}
|
|
/>
|
|
</ChartContainer>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
|
<ChartContainer
|
|
loading
|
|
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
|
chartHeight={340}
|
|
bleed={false}
|
|
/>
|
|
{/if}
|
|
|
|
{#if fetchedData && !loading}
|
|
<ModelPictogramTimeline
|
|
timestamps={fetchedData.timestamps}
|
|
hourlyFlat={fetchedData.hourly as Record<string, number[]>}
|
|
models={params.models || []}
|
|
sunrise={fetchedData.sunrise}
|
|
sunset={fetchedData.sunset}
|
|
timezone={fetchedData.timezone}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
|
|
|
<div class="mt-6 md:mt-10">
|
|
<ChartToolbar charts={liveCharts} fileName="model-comparison">
|
|
{#snippet controls()}
|
|
<div class="flex items-center gap-2">
|
|
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
|
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
|
>{m.legend_show()}</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">{m.compare_models_heading()}</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">
|
|
{m.compare_variables_heading()}
|
|
</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>
|