1035 lines
34 KiB
Svelte
1035 lines
34 KiB
Svelte
<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}
|