This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/routes/weather/compare/[location]/+page.svelte
T

1035 lines
34 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
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,
type ExportableChart,
type ExportLegendItem
} from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import {
CHART_COLORS,
CanvasChart,
type ChartAgreementStrip,
type ChartSeries,
groupRange,
isColumnUnit,
setGroupRange
} from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import {
type FriendlyWeatherError,
type ModelCompareResult,
fetchModelComparison,
humanizeWeatherError
} from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options';
import ComparisonSelectionDrawer from './ComparisonSelectionDrawer.svelte';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import {
cardinalDirection,
isWindDirection,
modelColor,
modelLabel,
modelMean,
precipitationAgreement,
sanitizeList,
validDirectionValues
} from './comparison';
import type { PageData } from './$types';
const CHART_GROUP = 'model-compare';
const MODEL_IDS = new Set(modelOptions.map((model) => model.value));
const MODEL_ORDER = new Map(modelOptions.map((model, index) => [model.value, index]));
const AUTOMATIC_SEAMLESS_MODEL_IDS = new Set(
modelGroups.find((group) => group.value === 'automatic_seamless')?.models.map((model) => model.value)
);
// Weather code and total cloud cover form one comparison feature. Cloud cover
// remains an auxiliary API field for pictogram shading, not a standalone chart.
const COMPARISON_HOURLY = hourly.map((group) =>
group.filter((variable) => variable.value !== 'cloud_cover')
);
const VARIABLE_IDS = new Set(COMPARISON_HOURLY.flat().map((variable) => variable.value));
const VARIABLE_ORDER = new Map(
COMPARISON_HOURLY.flat().map((variable, index) => [variable.value, index])
);
const SECONDS_PER_HOUR = 3600;
const FETCH_DEBOUNCE_MS = 300;
const STANDARD_COMPARE_VARIABLES = [
'temperature_2m',
'precipitation',
'wind_speed_10m',
'wind_direction_10m',
'weather_code',
'relative_humidity_2m'
];
let { data }: { data: PageData } = $props();
let location = $derived(data.location);
let mounted = $state(false);
let loading = $state(true);
let refreshing = $state(false);
let loadError = $state<FriendlyWeatherError | null>(null);
let retryNonce = $state(0);
let requestVersion = 0;
let activeController: AbortController | null = null;
let chartComponents: CanvasChart[] = $state([]);
let pictogramExporter: ExportableChart | null = $state(null);
let showLegend = $state(true);
const hiddenModels = new SvelteSet<string>();
let isMobile = $state(false);
let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116);
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
let selectionDrawerMode = $state<'models' | 'variables' | null>(null);
let params = $state({
...defaultParameters,
hourly: [...STANDARD_COMPARE_VARIABLES],
models: orderModels([
'ecmwf_ifs',
'meteofrance_arpege_world',
'ukmo_global_deterministic_10km',
'icon_global',
'gfs_global'
])
});
let appliedHourly = $state<string[]>([...STANDARD_COMPARE_VARIABLES]);
let appliedModels = $state<string[]>([...params.models]);
interface FetchedData {
result: ModelCompareResult;
selection: { models: string[]; hourly: string[] };
}
let fetchedData: FetchedData | null = $state(null);
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
let selectionEmpty = $derived(appliedModels.length === 0 || appliedHourly.length === 0);
let selectedModelWarning = $derived(params.models.length > 10);
function hasSameVariables(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((variable) => right.includes(variable));
}
function hasSameOrder(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function orderModels(values: string[]): string[] {
return [...values].sort(
(left, right) =>
(MODEL_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER) -
(MODEL_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER)
);
}
function orderVariables(values: string[]): string[] {
return [...values].sort(
(left, right) =>
(VARIABLE_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER) -
(VARIABLE_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER)
);
}
let variablesDirty = $derived(!hasSameVariables(params.hourly, appliedHourly));
let modelsDirty = $derived(!hasSameOrder(params.models, appliedModels));
let displayedVariablesStale = $derived(
fetchedData !== null && !hasSameVariables(fetchedData.selection.hourly, appliedHourly)
);
let displayedModelsStale = $derived(
fetchedData !== null && !hasSameOrder(fetchedData.selection.models, appliedModels)
);
let comparisonSelectionDirty = $derived(variablesDirty || modelsDirty);
let comparisonMuted = $derived(
comparisonSelectionDirty || displayedVariablesStale || displayedModelsStale
);
reportPageReady(() => mounted && !loading);
useHeroActions(heroActions);
$effect(() => storedLocation.set(data.location));
$effect(() => {
params.temperature_unit = $storedUnits.temperature_unit;
params.wind_speed_unit = $storedUnits.wind_speed_unit;
params.precipitation_unit = $storedUnits.precipitation_unit;
});
onMount(() => {
const url = get(page).url;
const urlModels = sanitizeList(readList(url, 'models'), MODEL_IDS);
const urlVars = sanitizeList(readList(url, 'vars'), VARIABLE_IDS);
if (urlModels) params.models = orderModels(urlModels);
else {
const selectedModel = get(storedModel);
if (
selectedModel !== 'best_match' &&
!AUTOMATIC_SEAMLESS_MODEL_IDS.has(selectedModel) &&
MODEL_IDS.has(selectedModel) &&
!params.models.includes(selectedModel)
) {
params.models = orderModels([...params.models, selectedModel]);
}
}
appliedModels = [...params.models];
if (urlVars) {
params.hourly = urlVars;
appliedHourly = [...urlVars];
}
const media = window.matchMedia('(max-width: 639px)');
const applyMedia = () => {
isMobile = media.matches;
};
applyMedia();
media.addEventListener('change', applyMedia);
mounted = true;
return () => media.removeEventListener('change', applyMedia);
});
onDestroy(() => activeController?.abort());
$effect(() => {
const models = appliedModels;
const vars = appliedHourly;
if (!mounted) return;
syncSearchParams(get(page).url, {
models: models.length ? models.join(',') : null,
vars: vars.length ? vars.join(',') : null
});
});
$effect(() => {
const modelList = [...appliedModels];
const hourlyVars = [...appliedHourly];
const loc = location;
const units = {
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'
};
void retryNonce;
if (!mounted) return;
const version = ++requestVersion;
activeController?.abort();
activeController = null;
if (modelList.length === 0 || hourlyVars.length === 0) {
fetchedData = null;
loading = false;
refreshing = false;
loadError = null;
return;
}
loading = fetchedData === null;
refreshing = fetchedData !== null;
loadError = null;
const timer = window.setTimeout(() => {
const controller = new AbortController();
activeController = controller;
fetchModelComparison(
{
latitude: loc.latitude,
longitude: loc.longitude,
hourlyVariables: [
...new Set([
...hourlyVars,
...(hourlyVars.includes('weather_code') ? ['cloud_cover'] : [])
])
],
models: modelList,
...units,
timezone: loc.timezone
},
{ signal: controller.signal }
)
.then((result) => {
if (version !== requestVersion) return;
fetchedData = { result, selection: { models: modelList, hourly: hourlyVars } };
loading = false;
refreshing = false;
})
.catch((error: unknown) => {
if (version !== requestVersion || controller.signal.aborted) return;
loadError = humanizeWeatherError(error);
loading = false;
refreshing = false;
});
}, FETCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
});
// ─── Chart ranges ────────────────────────────────────────────────────────────
function setRangeDays(days: number): void {
if (!fetchedData) return;
const { timestamps, timezone } = fetchedData.result;
const today = formatZoned(new Date(), timezone, 'yyyy-MM-dd');
const startIndex = timestamps.findIndex(
(timestamp) => formatZoned(new Date(timestamp), timezone, 'yyyy-MM-dd') === today
);
if (startIndex === -1) return;
const dayKeys: string[] = [];
let endIndex = timestamps.length;
for (let i = startIndex; i < timestamps.length; i++) {
const key = formatZoned(new Date(timestamps[i]), timezone, 'yyyy-MM-dd');
if (!dayKeys.includes(key)) dayKeys.push(key);
if (dayKeys.length > days) {
endIndex = i;
break;
}
}
const start = timestamps[startIndex] / 1000;
const end =
endIndex < timestamps.length
? timestamps[endIndex] / 1000
: timestamps[timestamps.length - 1] / 1000 + SECONDS_PER_HOUR;
if (liveCharts[0]) liveCharts[0].setRange(start, end);
else setGroupRange(CHART_GROUP, { start, end });
}
function resetZoom(): void {
if (liveCharts[0]) liveCharts[0].resetRange();
else setGroupRange(CHART_GROUP, null);
}
const rangePresets = [
{ label: m.range_today, apply: () => setRangeDays(1) },
{ label: m.range_3_days, apply: () => setRangeDays(3) },
{ label: m.range_5_days, apply: () => setRangeDays(5) },
{ label: m.range_all, apply: resetZoom }
];
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
// ─── Chart definitions ──────────────────────────────────────────────────────
const variableLabel = (variable: string): string => {
const labels: Record<string, () => string> = {
temperature_2m: m.var_temperature,
apparent_temperature: m.var_apparent,
dew_point_2m: m.var_dew_point,
precipitation: m.var_precipitation,
precipitation_probability: m.var_pop,
rain: m.var_rain,
showers: m.var_showers,
snowfall: m.var_snowfall,
wind_speed_10m: m.var_wind,
wind_gusts_10m: m.var_gusts,
relative_humidity_2m: m.var_humidity,
cloud_cover: m.var_cloud,
cloud_cover_low: m.var_cloud_low,
cloud_cover_mid: m.var_cloud_mid,
cloud_cover_high: m.var_cloud_high,
weather_code: m.compare_weather_conditions,
pressure_msl: m.var_pressure,
surface_pressure: m.var_surface_pressure
};
if (isWindDirection(variable)) {
const height = variable.match(/_(\d+)m$/)?.[1] ?? '10';
return m.compare_wind_direction_height({ height });
}
return (
labels[variable]?.() ?? variable.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
);
};
interface ChartDef {
title: string;
subtitle: string;
unit: string;
series: ChartSeries[];
zeroBaseLeft: boolean;
yMin?: number;
yMax?: number;
yTicks?: number[];
yTickFormat?: (value: number) => string;
agreementStrip?: ChartAgreementStrip;
}
let chartDefs = $derived.by((): ChartDef[] => {
if (!fetchedData) return [];
const { result, selection } = fetchedData;
const variables = selection.hourly.filter(
(variable) => variable !== 'weather_code' && variable !== 'cloud_cover'
);
return variables.map((variable) => {
const direction = isWindDirection(variable);
const unit = result.units[variable] ?? '';
const column = !direction && isColumnUnit(unit);
const series: ChartSeries[] = result.models.map((model) => ({
name: modelLabel(model.modelId),
type: direction ? 'point' : column ? 'bar' : 'line',
color: modelColor(model.modelId, selection.models),
data: direction
? validDirectionValues(model.variables[variable])
: (model.variables[variable] ?? []),
width: 2,
pointRadius: 3.25,
hidden: hiddenModels.has(model.modelId),
format: direction
? (value) => `${value.toFixed(0)}° ${cardinalDirection(value)}`
: undefined
}));
if (!direction && variable !== 'precipitation') {
series.push({
name: m.compare_model_mean(),
type: column ? 'bar' : 'line',
color: CHART_COLORS.average,
data: modelMean(result.models, variable, result.timestamps.length),
width: 4,
dashed: !column,
outline: !column
});
}
const percentage = [
'relative_humidity_2m',
'cloud_cover',
'cloud_cover_low',
'cloud_cover_mid',
'cloud_cover_high',
'precipitation_probability'
].includes(variable);
const floating = [
'pressure_msl',
'surface_pressure',
'temperature_2m',
'dew_point_2m'
].includes(variable);
const amountDecimals = unit === 'inch' || unit === 'in' ? 3 : 1;
const wetThreshold = unit === 'inch' || unit === 'in' ? 0.1 / 25.4 : 0.1;
const agreementStrip: ChartAgreementStrip | undefined =
variable === 'precipitation'
? {
points: precipitationAgreement(
result.models.filter((model) => !hiddenModels.has(model.modelId)),
variable,
result.timestamps.length,
wetThreshold
),
label: m.compare_precipitation_agreement(),
format: (point) =>
m.compare_precipitation_agreement_tooltip({
wet: point.wetCount,
available: point.availableCount,
median: point.median.toFixed(amountDecimals),
min: point.min.toFixed(amountDecimals),
max: point.max.toFixed(amountDecimals),
unit
})
}
: undefined;
return {
title: variableLabel(variable),
subtitle:
variable === 'precipitation'
? m.compare_precipitation_subtitle({ count: result.models.length })
: direction
? m.compare_direction_subtitle({ count: result.models.length })
: m.compare_scalar_subtitle({ count: result.models.length }),
unit,
series,
agreementStrip,
zeroBaseLeft: !floating,
yMin: direction || percentage ? 0 : undefined,
yMax: direction ? 360 : percentage ? 100 : undefined,
yTicks: direction ? [0, 90, 180, 270, 360] : undefined,
yTickFormat: direction
? (value) => `${cardinalDirection(value)} ${value.toFixed(0)}°`
: undefined
};
});
});
let timestampsSec = $derived.by((): number[] => {
const current: FetchedData | null = fetchedData;
return current ? current.result.timestamps.map((timestamp) => timestamp / 1000) : [];
});
let displayedModels = $derived.by(() => {
const current: FetchedData | null = fetchedData;
return current?.result.models ?? [];
});
let chartVariablesEmpty = $derived.by(() => {
const current: FetchedData | null = fetchedData;
return (
current?.selection.hourly.every(
(variable) => variable === 'weather_code' || variable === 'cloud_cover'
) ?? false
);
});
let noUsableData = $derived.by(() => {
if (!fetchedData || loading) return false;
return !fetchedData.result.models.some((model) =>
Object.values(model.variables).some((values) =>
values.some((value) => Number.isFinite(value))
)
);
});
let exportLegend = $derived.by((): ExportLegendItem[] => [
...displayedModels
.filter((model) => !hiddenModels.has(model.modelId))
.map((model) => ({
name: modelLabel(model.modelId),
color: modelColor(model.modelId, fetchedData?.selection.models),
style: 'point' as const
})),
...(chartDefs.some((def) =>
def.series.some((series) => series.name === m.compare_model_mean())
)
? [{ name: m.compare_model_mean(), color: CHART_COLORS.average, style: 'dashed' as const }]
: [])
]);
function toggleModel(modelId: string): void {
if (hiddenModels.has(modelId)) hiddenModels.delete(modelId);
else hiddenModels.add(modelId);
}
function toggleSelection(list: 'models' | 'hourly', value: string): void {
const values = params[list];
const nextValues = values.includes(value)
? values.filter((item) => item !== value)
: [...values, value];
params[list] = list === 'models' ? orderModels(nextValues) : orderVariables(nextValues);
}
function toggleGroupSelection(
list: 'models' | 'hourly',
values: string[],
select: boolean
): void {
const groupValues = new Set(values);
const nextValues = select
? [...new Set([...params[list], ...values])]
: params[list].filter((value) => !groupValues.has(value));
params[list] = list === 'models' ? orderModels(nextValues) : orderVariables(nextValues);
}
function restoreStandardVariables(): void {
params.hourly = [...STANDARD_COMPARE_VARIABLES];
}
function applyComparisonSelection(): void {
const variablesChanged = !hasSameVariables(params.hourly, appliedHourly);
const modelsChanged = !hasSameOrder(params.models, appliedModels);
if (modelsChanged) {
for (const modelId of params.models) {
if (!appliedModels.includes(modelId)) hiddenModels.delete(modelId);
}
appliedModels = [...params.models];
}
if (variablesChanged) appliedHourly = [...params.hourly];
if (!variablesChanged && !modelsChanged) retryNonce++;
}
const variableGroupLabels = [
m.compare_group_temperature,
m.compare_group_precipitation,
m.compare_group_clouds,
m.compare_group_wind,
m.compare_group_upper_air
];
const allHourlyVariables = COMPARISON_HOURLY.flat();
let selectedModels = $derived(
params.models.flatMap((value) => {
const model = modelOptions.find((candidate) => candidate.value === value);
return model ? [model] : [];
})
);
let modelDrawerGroups = $derived(
modelGroups.map((group) => ({
value: group.value,
label: group.label,
options: group.models.map((model) => ({
value: model.value,
label: model.label,
metadata: [model.resolution, model.update].filter(Boolean).join(' · ')
}))
}))
);
let selectedVariables = $derived(
params.hourly.flatMap((value) => {
const variable = allHourlyVariables.find((candidate) => candidate.value === value);
return variable ? [variable] : [];
})
);
let standardVariablesActive = $derived(
params.hourly.length === STANDARD_COMPARE_VARIABLES.length &&
STANDARD_COMPARE_VARIABLES.every((variable) => params.hourly.includes(variable))
);
let variableDrawerGroups = $derived(
COMPARISON_HOURLY.map((variables, index) => ({
value: `variables_${index}`,
label: variableGroupLabels[index](),
options: variables.map((variable) => ({
value: variable.value,
label: variableLabel(variable.value)
}))
}))
);
</script>
<svelte:head>
<title>{m.page_compare_title()} | Drizz.li</title>
<link rel="canonical" href="https://drizz.li/weather/compare" />
<meta name="description" content={m.page_compare_description()} />
</svelte:head>
{#snippet rangeControls(compact = false)}
<div class="flex min-w-max items-center gap-2" aria-label={m.range_group_aria()}>
{#if zoomActive}
<button
type="button"
class="rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary"
onclick={resetZoom}>{m.reset_zoom()}</button
>
{/if}
<div class="inline-flex rounded-lg bg-muted p-0.5 text-xs font-semibold" role="group">
{#each rangePresets as preset (preset.label())}
<button
type="button"
class="rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm {compact
? 'min-h-9'
: ''}"
onclick={preset.apply}>{preset.label()}</button
>
{/each}
</div>
</div>
{/snippet}
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-20 z-40 hidden items-center gap-3 lg:flex">
<span class="text-xs text-muted-foreground">
{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>
{@render rangeControls()}
</div>
{/snippet}
<div class="-mx-3 mb-3 overflow-x-auto px-3 lg:hidden">{@render rangeControls(true)}</div>
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm"
role="alert"
>
<p class="font-semibold text-destructive">{loadError.title}</p>
{#if loadError.hint}<p class="mt-0.5 text-destructive/90">{loadError.hint}</p>{/if}
{#if fetchedData}<p class="mt-1 text-xs text-destructive/80">
{m.compare_showing_previous()}
</p>{/if}
<button
type="button"
class="mt-2 rounded-md border border-destructive/40 bg-background px-3 py-1 text-xs font-semibold text-destructive"
onclick={() => retryNonce++}>{m.action_try_again()}</button
>
{#if loadError.detail}
<details class="mt-2 text-xs text-destructive/70">
<summary>{m.error_technical_details()}</summary>
<p class="mt-1 font-mono break-all">{loadError.detail}</p>
</details>
{/if}
</div>
{/if}
{#if selectionEmpty}
<div
class="rounded-xl border border-dashed border-border bg-muted/30 px-4 py-10 text-center text-sm text-muted-foreground"
>
{m.compare_empty_selection()}
</div>
{:else if noUsableData}
<div
class="rounded-xl border border-amber-300/60 bg-amber-50 px-4 py-4 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
>
{m.compare_no_data()}
</div>
{:else if loadError && !fetchedData}
<!-- The actionable error panel above replaces the chart until a retry succeeds. -->
{:else if fetchedData && chartVariablesEmpty}
<div
class="rounded-xl border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground"
>
{m.compare_weather_codes_only()}
</div>
{:else if fetchedData}
<div
class="relative -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"
>
<div
class="transition-[opacity,filter] duration-200 {comparisonMuted
? 'pointer-events-none opacity-35 saturate-50'
: ''}"
>
{#each chartDefs as def, i (def.title)}
<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">
<h2 class="text-sm font-bold tracking-tight">{def.title}</h2>
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
</div>
<ChartContainer
loading={loading && !fetchedData}
chartCount={1}
chartHeight={280}
minWidth={520}
bleed={false}
>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.result.timezone}
series={def.series}
agreementStrip={def.agreementStrip}
bands={fetchedData.result.daylightBands}
unit={def.unit}
zeroBaseLeft={def.zeroBaseLeft}
yMin={def.yMin}
yMax={def.yMax}
yTicks={def.yTicks}
yTickFormat={def.yTickFormat}
plotInsetLeft={comparisonPlotInsetLeft}
plotInsetRight={comparisonPlotInsetRight}
showCredit={i === chartDefs.length - 1}
height={280}
group={CHART_GROUP}
ariaLabel={`${def.title}. ${def.subtitle}`}
/>
</ChartContainer>
</div>
{/each}
</div>
{#if comparisonMuted}
<div
class="absolute inset-0 z-40 flex items-start justify-center bg-background/30 px-4 py-6 backdrop-blur-[1px]"
>
<div
class="sticky top-24 flex flex-col items-center gap-2 rounded-xl border border-border/70 bg-background/95 p-3 text-center shadow-lg"
role="status"
>
<span class="text-xs font-semibold text-muted-foreground">
{m.compare_selection_pending()}
</span>
<button
type="button"
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-70"
disabled={(displayedVariablesStale || displayedModelsStale) && refreshing}
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
</div>
</div>
{/if}
</div>
{:else}
<ChartContainer
loading
chartCount={appliedHourly.filter((v) => v !== 'weather_code' && v !== 'cloud_cover').length || 1}
chartHeight={300}
bleed={false}
/>
{/if}
{#if fetchedData && !loading && appliedHourly.includes('weather_code')}
<div
class="transition-[opacity,filter] duration-200 {comparisonMuted
? 'pointer-events-none opacity-35 saturate-50'
: ''}"
>
<ModelPictogramTimeline
timestamps={fetchedData.result.timestamps}
models={fetchedData.result.models}
modelOrder={fetchedData.selection.models}
sunrise={fetchedData.result.sunrise}
sunset={fetchedData.result.sunset}
timezone={fetchedData.result.timezone}
group={CHART_GROUP}
plotInsetLeft={comparisonPlotInsetLeft}
plotInsetRight={comparisonPlotInsetRight}
registerExporter={(exporter) => (pictogramExporter = exporter)}
/>
</div>
{/if}
{#if fetchedData && displayedModels.length > 0}
<div
class="mt-5 rounded-xl border border-border/70 bg-card px-3 py-2.5 transition-opacity {comparisonMuted
? 'pointer-events-none opacity-40'
: ''}"
>
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold text-muted-foreground">{m.compare_model_colors()}</p>
<div class="flex items-center gap-2">
<Switch id="show_legend" bind:checked={showLegend} />
<Label for="show_legend" class="text-xs">{m.legend_show()}</Label>
</div>
</div>
{#if showLegend}
<div
class="-mx-1 mt-2 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap"
transition:fade={{ duration: 150 }}
>
{#each displayedModels as model (model.modelId)}
<button
type="button"
class="flex min-h-8 shrink-0 items-center gap-1.5 text-xs {hiddenModels.has(
model.modelId
)
? 'opacity-40'
: ''}"
onclick={() => toggleModel(model.modelId)}
aria-pressed={!hiddenModels.has(model.modelId)}
>
<span
class="size-2.5 rounded-full"
style:background-color={modelColor(model.modelId, fetchedData.selection.models)}
></span>
{modelLabel(model.modelId)}
</button>
{/each}
{#if chartDefs.some( (def) => def.series.some((series) => series.name === m.compare_model_mean()) )}
<span class="flex shrink-0 items-center gap-1.5 text-xs">
<span class="w-4 border-t-2 border-dashed" style:border-color={CHART_COLORS.average}
></span>
{m.compare_model_mean()}
</span>
{/if}
</div>
{/if}
</div>
<div
class="mt-4 flex justify-end transition-opacity {comparisonMuted
? 'pointer-events-none opacity-40'
: ''}"
>
<ChartToolbar
charts={[
...chartDefs.map((def, i) => ({ chart: chartComponents[i], title: def.title })),
{ chart: pictogramExporter, title: m.compare_timeline_title() }
]}
fileName="model-comparison"
exportOptions={{
title: `${location.name} · ${m.page_compare_subtitle()}`,
legend: exportLegend
}}
/>
</div>
{/if}
<!-- Compact summaries keep the page scannable; full editing happens in the shared drawer. -->
<section class="mt-6 border-t border-border/60 pt-5 md:mt-10" aria-labelledby="models">
<div class="flex items-baseline justify-between gap-3">
<h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2>
<span class="shrink-0 text-sm text-muted-foreground">
{params.models.length}/{modelOptions.length}
</span>
</div>
{#if selectedModelWarning}
<p
class="mt-2 rounded-md bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
role="status"
>
{m.compare_many_models_warning({ count: params.models.length })}
</p>
{/if}
<div class="mt-3 rounded-xl border border-border/70 bg-card p-3 shadow-sm sm:p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-bold">{m.compare_selected_models()}</h3>
{#if modelsDirty}
<span
class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold tracking-wide text-amber-700 uppercase dark:text-amber-300"
>
{m.compare_selection_pending()}
</span>
{/if}
</div>
{#if selectedModels.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each selectedModels as model, index (model.value)}
<button
type="button"
class="inline-flex min-h-8 max-w-full items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs hover:border-destructive/40 hover:bg-destructive/5"
onclick={() => toggleSelection('models', model.value)}
aria-label={m.compare_remove_model({ model: model.label })}
>
<span class="text-[10px] font-bold text-muted-foreground tabular-nums"
>{index + 1}</span
>
<span
class="size-2.5 shrink-0 rounded-full"
style:background-color={modelColor(model.value, params.models)}
></span>
<span class="truncate">{model.label}</span>
<span aria-hidden="true" class="text-muted-foreground">×</span>
</button>
{/each}
</div>
{:else}
<p class="mt-1.5 text-xs text-muted-foreground">{m.compare_no_models_selected()}</p>
{/if}
</div>
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={() => (selectionDrawerMode = 'models')}
aria-haspopup="dialog"
>
{m.compare_customize_models()}
</button>
{#if modelsDirty}
<button
type="button"
class="min-h-9 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90"
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
{/if}
</div>
</div>
</div>
</section>
<section
class="mt-5 border-t border-border/60 pt-5 md:mt-10"
aria-labelledby="hourly_weather_variables"
>
<div class="flex items-baseline justify-between gap-3">
<h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
{m.compare_variables_heading()}
</h2>
<span class="shrink-0 text-sm text-muted-foreground">
{params.hourly.length}/{allHourlyVariables.length}
</span>
</div>
<div
class="mt-3 rounded-xl border bg-card shadow-sm {standardVariablesActive
? 'border-primary/35'
: 'border-border/70'}"
>
<div class="flex flex-col gap-3 p-3 sm:flex-row sm:items-start sm:justify-between sm:p-4">
<div class="flex min-w-0 gap-3">
<span
class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border {standardVariablesActive
? 'border-primary bg-primary'
: 'border-muted-foreground/40 bg-muted'}"
aria-hidden="true"
>
{#if standardVariablesActive}<span class="size-2 rounded-full bg-primary-foreground"></span>{/if}
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-bold">{m.compare_standard_preset()}</h3>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase {variablesDirty
? 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
: standardVariablesActive
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'}"
>
{variablesDirty
? m.compare_variables_pending()
: standardVariablesActive
? m.compare_preset_active()
: m.compare_custom_selection({ count: params.hourly.length })}
</span>
</div>
<p class="mt-0.5 text-sm text-muted-foreground">
{m.compare_standard_preset_description()}
</p>
{#if selectedVariables.length > 0}
<p class="mt-2 text-xs leading-relaxed text-foreground/80">
{selectedVariables.map((variable) => variableLabel(variable.value)).join(' · ')}
</p>
{/if}
</div>
</div>
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
{#if !standardVariablesActive}
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={restoreStandardVariables}
>
{m.compare_restore_defaults()}
</button>
{/if}
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={() => (selectionDrawerMode = 'variables')}
aria-haspopup="dialog"
>
{m.compare_customize_variables()}
</button>
{#if variablesDirty}
<button
type="button"
class="min-h-9 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90"
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
{/if}
</div>
</div>
</div>
</section>
{#if selectionDrawerMode === 'models'}
<ComparisonSelectionDrawer
open
mode="models"
groups={modelDrawerGroups}
selected={params.models}
dirty={comparisonSelectionDirty}
onToggle={(value) => toggleSelection('models', value)}
onToggleGroup={(values, select) => toggleGroupSelection('models', values, select)}
onApply={applyComparisonSelection}
onClose={() => (selectionDrawerMode = null)}
/>
{:else if selectionDrawerMode === 'variables'}
<ComparisonSelectionDrawer
open
mode="variables"
groups={variableDrawerGroups}
selected={params.hourly}
dirty={comparisonSelectionDirty}
onToggle={(value) => toggleSelection('hourly', value)}
onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)}
onApply={applyComparisonSelection}
onClose={() => (selectionDrawerMode = null)}
onRestoreDefaults={restoreStandardVariables}
/>
{/if}