5 Commits
Author SHA1 Message Date
terraputix 8b58bc01d7 review fixes 2026-08-02 23:05:09 +02:00
terraputix edee9fe33d heading improvement 2026-08-02 22:45:52 +02:00
terraputix 5541df20e1 add aladin models 2026-08-02 22:42:38 +02:00
terraputix 77af339df4 redesign model and variable selection 2026-08-02 22:35:36 +02:00
terraputix ea9eb17d75 multimodel improvements 2026-08-02 22:24:51 +02:00
20 changed files with 2787 additions and 824 deletions
+47
View File
@@ -281,8 +281,55 @@
"model_selector_aria": "Auswahl: {label}",
"compare_models_heading": "Modelle",
"compare_variables_heading": "Stündliche Wettervariablen",
"compare_standard_preset": "Standardvergleich",
"compare_weather_conditions": "Wetterlage & Gesamtbewölkung",
"compare_wind_direction_height": "Windrichtung in {height} m",
"compare_standard_preset_description": "Ein ausgewogener Ausgangspunkt für den Vergleich von Vorhersagemodellen.",
"compare_preset_active": "Aktiv",
"compare_custom_selection": "Benutzerdefiniert · {count} ausgewählt",
"compare_customize_variables": "Variablen anpassen",
"compare_restore_defaults": "Standard wiederherstellen",
"compare_variables_pending": "Variablenauswahl geändert",
"compare_selected_models": "Ausgewählte Modelle",
"compare_selection_pending": "Auswahl geändert",
"compare_apply_selection": "Anwenden & Diagramme neu laden",
"compare_customize_models": "Modelle anpassen",
"compare_edit_models": "Modelle bearbeiten",
"compare_edit_variables": "Variablen bearbeiten",
"compare_edit_models_description": "Modelle suchen oder ganze Anbietergruppen auswählen.",
"compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.",
"compare_search_models": "Modelle suchen",
"compare_search_variables": "Variablen suchen",
"compare_only_selected": "Nur ausgewählte",
"compare_select_group": "Alle auswählen",
"compare_clear_group": "Alle abwählen",
"compare_no_matching_options": "Keine passenden Optionen.",
"compare_no_models_selected": "Keine Modelle ausgewählt.",
"compare_remove_model": "{model} entfernen",
"compare_timeline_title": "Zeitleiste des Modellvergleichs",
"compare_model_label": "Modell",
"page_compare_title": "Wettermodelle vergleichen",
"page_compare_description": "Stündliche Vorhersagen mehrerer Wettermodelle für jeden Ort vergleichen.",
"compare_direction_subtitle": "Richtungsstreuung über {count} Modelle · kein Mittelwert",
"compare_scalar_subtitle": "Über {count} Modelle · gestrichelt = Modellmittel",
"compare_precipitation_subtitle": "Über {count} Modelle · unterer Streifen = Niederschlagsübereinstimmung",
"compare_precipitation_agreement": "Übereinstimmung",
"compare_precipitation_agreement_tooltip": "{wet}/{available} mit Niederschlag · Median {median} {unit} · Spanne {min}{max} {unit}",
"compare_model_mean": "Modellmittel",
"compare_showing_previous": "Der vorherige erfolgreiche Vergleich bleibt sichtbar.",
"compare_empty_selection": "Mindestens ein Modell und eine Wettervariable auswählen.",
"compare_no_data": "Keines der ausgewählten Modelle liefert für diesen Ort und diese Auswahl nutzbare Daten.",
"compare_weather_codes_only": "Wetterlage und Gesamtbewölkung erscheinen unten in der Modellzeitleiste. Für ein Diagramm eine weitere Variable auswählen.",
"compare_model_colors": "Modellfarben",
"compare_group_temperature": "Temperatur & Feuchte",
"compare_group_precipitation": "Niederschlag & Wetter",
"compare_group_clouds": "Druck & Wolken",
"compare_group_wind": "Wind & Atmosphäre",
"compare_group_upper_air": "Temperatur in der Höhe",
"compare_many_models_warning": "{count} Modelle sind ausgewählt. Der Vergleich kann langsamer und die Farben schwerer unterscheidbar sein.",
"compare_timeline_hint": "Wetter nach Modell; der Piktogramm-Hintergrund zeigt die Gesamtbewölkung",
"compare_timeline_scroll_aria": "Scrollbare Wetterzeitleiste der Modelle",
"compare_timeline_caption": "Stündliche Wetterbedingungen nach Vorhersagemodell",
"ensemble_trimmed": "Das Ensemble dieses Modells reicht nur etwa {days} Tage voraus, die Streuung ist auf den verfügbaren Zeitraum gekürzt.",
"seasonal_explainer_before": "Eine saisonale Vorhersage zeigt, wie stark ein ganzer Monat voraussichtlich",
"seasonal_explainer_strong": "von seiner Klimanormalen abweicht",
+47
View File
@@ -281,8 +281,55 @@
"model_selector_aria": "{label} selection",
"compare_models_heading": "Models",
"compare_variables_heading": "Hourly Weather Variables",
"compare_standard_preset": "Standard comparison",
"compare_weather_conditions": "Weather conditions & cloud cover",
"compare_wind_direction_height": "Wind direction at {height} m",
"compare_standard_preset_description": "A balanced starting point for comparing forecast models.",
"compare_preset_active": "Active",
"compare_custom_selection": "Custom · {count} selected",
"compare_customize_variables": "Customize variables",
"compare_restore_defaults": "Restore defaults",
"compare_variables_pending": "Variable selection changed",
"compare_selected_models": "Selected models",
"compare_selection_pending": "Selection changed",
"compare_apply_selection": "Apply & reload charts",
"compare_customize_models": "Customize models",
"compare_edit_models": "Edit models",
"compare_edit_variables": "Edit variables",
"compare_edit_models_description": "Search models or select complete provider families.",
"compare_edit_variables_description": "Search variables or select complete thematic groups.",
"compare_search_models": "Search models",
"compare_search_variables": "Search variables",
"compare_only_selected": "Only selected",
"compare_select_group": "Select all",
"compare_clear_group": "Clear all",
"compare_no_matching_options": "No matching options.",
"compare_no_models_selected": "No models selected.",
"compare_remove_model": "Remove {model}",
"compare_timeline_title": "Model Comparison Timeline",
"compare_model_label": "Model",
"page_compare_title": "Weather model comparison",
"page_compare_description": "Compare hourly forecasts from multiple weather models for any location.",
"compare_direction_subtitle": "Direction spread across {count} models · no mean",
"compare_scalar_subtitle": "Across {count} models · dashed = model mean",
"compare_precipitation_subtitle": "Across {count} models · lower strip = precipitation agreement",
"compare_precipitation_agreement": "Agreement",
"compare_precipitation_agreement_tooltip": "{wet}/{available} wet · median {median} {unit} · range {min}{max} {unit}",
"compare_model_mean": "Model mean",
"compare_showing_previous": "The previous successful comparison remains visible.",
"compare_empty_selection": "Select at least one model and one weather variable to build a comparison.",
"compare_no_data": "None of the selected models provides usable data for this location and selection.",
"compare_weather_codes_only": "Weather conditions and cloud cover are shown in the model timeline below. Select another variable to add a chart.",
"compare_model_colors": "Model colors",
"compare_group_temperature": "Temperature & humidity",
"compare_group_precipitation": "Precipitation & conditions",
"compare_group_clouds": "Pressure & clouds",
"compare_group_wind": "Wind & atmosphere",
"compare_group_upper_air": "Upper-air temperature",
"compare_many_models_warning": "{count} models are selected. The comparison may be slower and individual colors harder to distinguish.",
"compare_timeline_hint": "Conditions by model; pictogram backgrounds show total cloud cover",
"compare_timeline_scroll_aria": "Scrollable model weather timeline",
"compare_timeline_caption": "Hourly weather conditions by forecast model",
"ensemble_trimmed": "This model's ensemble only reaches about {days} days ahead, the spread is trimmed to its available range.",
"seasonal_explainer_before": "A seasonal forecast shows how a whole month is likely to",
"seasonal_explainer_strong": "depart from its climate normal",
+47
View File
@@ -281,8 +281,55 @@
"model_selector_aria": "Selección de {label}",
"compare_models_heading": "Modelos",
"compare_variables_heading": "Variables meteorológicas horarias",
"compare_standard_preset": "Comparación estándar",
"compare_weather_conditions": "Condiciones y nubosidad",
"compare_wind_direction_height": "Dirección del viento a {height} m",
"compare_standard_preset_description": "Un punto de partida equilibrado para comparar modelos de previsión.",
"compare_preset_active": "Activa",
"compare_custom_selection": "Personalizada · {count} seleccionadas",
"compare_customize_variables": "Personalizar variables",
"compare_restore_defaults": "Restablecer selección estándar",
"compare_variables_pending": "Selección de variables modificada",
"compare_selected_models": "Modelos seleccionados",
"compare_selection_pending": "Selección modificada",
"compare_apply_selection": "Aplicar y recargar gráficos",
"compare_customize_models": "Personalizar modelos",
"compare_edit_models": "Editar modelos",
"compare_edit_variables": "Editar variables",
"compare_edit_models_description": "Busca modelos o selecciona familias completas de proveedores.",
"compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.",
"compare_search_models": "Buscar modelos",
"compare_search_variables": "Buscar variables",
"compare_only_selected": "Solo seleccionados",
"compare_select_group": "Seleccionar todo",
"compare_clear_group": "Deseleccionar todo",
"compare_no_matching_options": "No hay opciones coincidentes.",
"compare_no_models_selected": "No hay modelos seleccionados.",
"compare_remove_model": "Quitar {model}",
"compare_timeline_title": "Cronología de comparación de modelos",
"compare_model_label": "Modelo",
"page_compare_title": "Comparación de modelos meteorológicos",
"page_compare_description": "Compara previsiones horarias de varios modelos meteorológicos para cualquier lugar.",
"compare_direction_subtitle": "Dispersión de dirección entre {count} modelos · sin media",
"compare_scalar_subtitle": "Entre {count} modelos · discontinuo = media de modelos",
"compare_precipitation_subtitle": "Entre {count} modelos · franja inferior = concordancia de precipitación",
"compare_precipitation_agreement": "Concordancia",
"compare_precipitation_agreement_tooltip": "{wet}/{available} con precipitación · mediana {median} {unit} · rango {min}{max} {unit}",
"compare_model_mean": "Media de modelos",
"compare_showing_previous": "La comparación anterior sigue visible.",
"compare_empty_selection": "Selecciona al menos un modelo y una variable meteorológica.",
"compare_no_data": "Ninguno de los modelos seleccionados ofrece datos útiles para este lugar y selección.",
"compare_weather_codes_only": "Las condiciones y la nubosidad aparecen en la cronología inferior. Selecciona otra variable para añadir un gráfico.",
"compare_model_colors": "Colores de los modelos",
"compare_group_temperature": "Temperatura y humedad",
"compare_group_precipitation": "Precipitación y condiciones",
"compare_group_clouds": "Presión y nubes",
"compare_group_wind": "Viento y atmósfera",
"compare_group_upper_air": "Temperatura en altura",
"compare_many_models_warning": "Hay {count} modelos seleccionados. La comparación puede ser más lenta y los colores más difíciles de distinguir.",
"compare_timeline_hint": "Condiciones por modelo; el fondo de los pictogramas indica la nubosidad total",
"compare_timeline_scroll_aria": "Cronología meteorológica desplazable por modelos",
"compare_timeline_caption": "Condiciones meteorológicas horarias por modelo de previsión",
"ensemble_trimmed": "El ensemble de este modelo solo llega unos {days} días, así que la dispersión se recorta a su rango disponible.",
"seasonal_explainer_before": "Una previsión estacional muestra cuánto es probable que todo un mes",
"seasonal_explainer_strong": "se desvíe de su normal climática",
+47
View File
@@ -281,8 +281,55 @@
"model_selector_aria": "Sélection : {label}",
"compare_models_heading": "Modèles",
"compare_variables_heading": "Variables météo horaires",
"compare_standard_preset": "Comparaison standard",
"compare_weather_conditions": "Conditions & couverture nuageuse",
"compare_wind_direction_height": "Direction du vent à {height} m",
"compare_standard_preset_description": "Un point de départ équilibré pour comparer les modèles de prévision.",
"compare_preset_active": "Active",
"compare_custom_selection": "Personnalisée · {count} sélectionnées",
"compare_customize_variables": "Personnaliser les variables",
"compare_restore_defaults": "Rétablir la sélection standard",
"compare_variables_pending": "Sélection des variables modifiée",
"compare_selected_models": "Modèles sélectionnés",
"compare_selection_pending": "Sélection modifiée",
"compare_apply_selection": "Appliquer et recharger les graphiques",
"compare_customize_models": "Personnaliser les modèles",
"compare_edit_models": "Modifier les modèles",
"compare_edit_variables": "Modifier les variables",
"compare_edit_models_description": "Recherchez des modèles ou sélectionnez des fournisseurs entiers.",
"compare_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques entiers.",
"compare_search_models": "Rechercher des modèles",
"compare_search_variables": "Rechercher des variables",
"compare_only_selected": "Sélection uniquement",
"compare_select_group": "Tout sélectionner",
"compare_clear_group": "Tout désélectionner",
"compare_no_matching_options": "Aucune option correspondante.",
"compare_no_models_selected": "Aucun modèle sélectionné.",
"compare_remove_model": "Supprimer {model}",
"compare_timeline_title": "Chronologie de comparaison des modèles",
"compare_model_label": "Modèle",
"page_compare_title": "Comparaison des modèles météo",
"page_compare_description": "Comparez les prévisions horaires de plusieurs modèles météo pour nimporte quel lieu.",
"compare_direction_subtitle": "Dispersion des directions sur {count} modèles · sans moyenne",
"compare_scalar_subtitle": "Sur {count} modèles · pointillés = moyenne des modèles",
"compare_precipitation_subtitle": "Sur {count} modèles · bande inférieure = accord sur les précipitations",
"compare_precipitation_agreement": "Accord",
"compare_precipitation_agreement_tooltip": "{wet}/{available} avec précipitations · médiane {median} {unit} · plage {min}{max} {unit}",
"compare_model_mean": "Moyenne des modèles",
"compare_showing_previous": "La dernière comparaison réussie reste affichée.",
"compare_empty_selection": "Sélectionnez au moins un modèle et une variable météo.",
"compare_no_data": "Aucun des modèles sélectionnés ne fournit de données exploitables pour ce lieu et cette sélection.",
"compare_weather_codes_only": "Les conditions et la couverture nuageuse sont affichées dans la chronologie ci-dessous. Sélectionnez une autre variable pour ajouter un graphique.",
"compare_model_colors": "Couleurs des modèles",
"compare_group_temperature": "Température et humidité",
"compare_group_precipitation": "Précipitations et conditions",
"compare_group_clouds": "Pression et nuages",
"compare_group_wind": "Vent et atmosphère",
"compare_group_upper_air": "Température en altitude",
"compare_many_models_warning": "{count} modèles sont sélectionnés. La comparaison peut être plus lente et les couleurs plus difficiles à distinguer.",
"compare_timeline_hint": "Conditions par modèle ; le fond des pictogrammes indique la couverture nuageuse totale",
"compare_timeline_scroll_aria": "Chronologie météo des modèles à défilement horizontal",
"compare_timeline_caption": "Conditions météo horaires par modèle de prévision",
"ensemble_trimmed": "L'ensemble de ce modèle ne va qu'à environ {days} jours, la dispersion est donc limitée à sa portée disponible.",
"seasonal_explainer_before": "Une prévision saisonnière montre dans quelle mesure un mois entier devrait",
"seasonal_explainer_strong": "s'écarter de sa normale climatique",
+47
View File
@@ -281,8 +281,55 @@
"model_selector_aria": "Selezione di {label}",
"compare_models_heading": "Modelli",
"compare_variables_heading": "Variabili meteo orarie",
"compare_standard_preset": "Confronto standard",
"compare_weather_conditions": "Condizioni e copertura nuvolosa",
"compare_wind_direction_height": "Direzione del vento a {height} m",
"compare_standard_preset_description": "Un punto di partenza equilibrato per confrontare i modelli previsionali.",
"compare_preset_active": "Attivo",
"compare_custom_selection": "Personalizzata · {count} selezionate",
"compare_customize_variables": "Personalizza le variabili",
"compare_restore_defaults": "Ripristina la selezione standard",
"compare_variables_pending": "Selezione delle variabili modificata",
"compare_selected_models": "Modelli selezionati",
"compare_selection_pending": "Selezione modificata",
"compare_apply_selection": "Applica e ricarica i grafici",
"compare_customize_models": "Personalizza modelli",
"compare_edit_models": "Modifica modelli",
"compare_edit_variables": "Modifica variabili",
"compare_edit_models_description": "Cerca modelli o seleziona intere famiglie di fornitori.",
"compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.",
"compare_search_models": "Cerca modelli",
"compare_search_variables": "Cerca variabili",
"compare_only_selected": "Solo selezionati",
"compare_select_group": "Seleziona tutto",
"compare_clear_group": "Deseleziona tutto",
"compare_no_matching_options": "Nessuna opzione corrispondente.",
"compare_no_models_selected": "Nessun modello selezionato.",
"compare_remove_model": "Rimuovi {model}",
"compare_timeline_title": "Cronologia del confronto tra modelli",
"compare_model_label": "Modello",
"page_compare_title": "Confronto dei modelli meteo",
"page_compare_description": "Confronta le previsioni orarie di più modelli meteo per qualsiasi località.",
"compare_direction_subtitle": "Dispersione della direzione tra {count} modelli · senza media",
"compare_scalar_subtitle": "Tra {count} modelli · tratteggiato = media dei modelli",
"compare_precipitation_subtitle": "Tra {count} modelli · fascia inferiore = concordanza sulle precipitazioni",
"compare_precipitation_agreement": "Concordanza",
"compare_precipitation_agreement_tooltip": "{wet}/{available} con precipitazioni · mediana {median} {unit} · intervallo {min}{max} {unit}",
"compare_model_mean": "Media dei modelli",
"compare_showing_previous": "Il confronto precedente rimane visibile.",
"compare_empty_selection": "Seleziona almeno un modello e una variabile meteo.",
"compare_no_data": "Nessuno dei modelli selezionati fornisce dati utilizzabili per questa località e selezione.",
"compare_weather_codes_only": "Le condizioni e la copertura nuvolosa sono mostrate nella cronologia in basso. Seleziona unaltra variabile per aggiungere un grafico.",
"compare_model_colors": "Colori dei modelli",
"compare_group_temperature": "Temperatura e umidità",
"compare_group_precipitation": "Precipitazioni e condizioni",
"compare_group_clouds": "Pressione e nuvole",
"compare_group_wind": "Vento e atmosfera",
"compare_group_upper_air": "Temperatura in quota",
"compare_many_models_warning": "Sono selezionati {count} modelli. Il confronto può essere più lento e i colori più difficili da distinguere.",
"compare_timeline_hint": "Condizioni per modello; lo sfondo dei pittogrammi indica la copertura nuvolosa totale",
"compare_timeline_scroll_aria": "Cronologia meteo scorrevole dei modelli",
"compare_timeline_caption": "Condizioni meteo orarie per modello di previsione",
"ensemble_trimmed": "L'ensemble di questo modello arriva solo a circa {days} giorni, quindi la dispersione è ridotta al periodo disponibile.",
"seasonal_explainer_before": "Una previsione stagionale mostra quanto un intero mese potrebbe",
"seasonal_explainer_strong": "discostarsi dalla sua normale climatica",
+249 -40
View File
@@ -24,13 +24,15 @@
/** Series display name (used in legend and tooltip) */
name: string;
/** Render style */
type: 'line' | 'bar';
type: 'line' | 'bar' | 'point';
/** Any CSS color string */
color: string;
/** One value per timestamp; null values break lines */
data: (number | null)[];
/** Line width in px (default 2); 0 draws only the area fill */
width?: number;
/** Radius in px for point series (default 3). */
pointRadius?: number;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
fill?: boolean;
/** Area fill coloured by the value scale (segmentColor), fading to
@@ -74,6 +76,20 @@
cloudLayer?: 'high' | 'mid' | 'low';
}
export interface ChartAgreementPoint {
wetCount: number;
availableCount: number;
median: number;
min: number;
max: number;
}
export interface ChartAgreementStrip {
points: (ChartAgreementPoint | null)[];
label: string;
format: (point: ChartAgreementPoint) => string;
}
interface GroupState {
range: { start: number; end: number } | null;
hover: number | null;
@@ -101,6 +117,17 @@
}
}
/** Register a non-chart surface that participates in synchronized interactions. */
export function registerGroupMember(name: string): () => void {
acquireGroup(name);
let registered = true;
return () => {
if (!registered) return;
registered = false;
releaseGroup(name);
};
}
/**
* Drive the shared crosshair of a chart group from the outside (e.g. hovering
* the hourly table). `time` is epoch seconds, or null to clear. No-op if no
@@ -108,7 +135,16 @@
*/
export function setGroupHover(name: string, time: number | null): void {
const state = groups[name];
if (state) state.hover = time;
if (state && state.hover !== time) state.hover = time;
}
/** Set the synchronized visible time range for a registered chart group. */
export function setGroupRange(
name: string,
range: { start: number; end: number } | null
): void {
const state = groups[name];
if (state) state.range = range;
}
/** Current shared zoom range of a group (null = full range), reactive. */
@@ -151,6 +187,8 @@
windArrows?: { t: number; deg: number }[];
/** Highlighted time range (epoch seconds), e.g. the selected day */
highlight?: { start: number; end: number };
/** Compact per-timestamp model-agreement band below the plot. */
agreementStrip?: ChartAgreementStrip;
/** Unit label for the left y axis (also used in tooltip values) */
unit?: string;
/** Unit label for the right y axis; when set, right-axis labels are drawn */
@@ -167,6 +205,10 @@
yPadBottom?: number;
/** Fixed left-axis maximum */
yMax?: number;
/** Explicit left-axis tick values (for categorical/fixed domains). */
yTicks?: number[];
/** Custom left-axis tick formatter. */
yTickFormat?: (value: number) => string;
/** Force the derived left axis to include zero (default true) */
zeroBaseLeft?: boolean;
/** Fixed right-axis minimum (default 0) */
@@ -181,6 +223,10 @@
/** Reserve this many top icon-rows even if this chart has fewer, so a row
* of stacked charts share the same plot rectangle */
reserveTopRows?: number;
/** Override the plot's left inset so heterogeneous synchronized panels align. */
plotInsetLeft?: number;
/** Override the plot's right inset so heterogeneous synchronized panels align. */
plotInsetRight?: number;
/** Chart title drawn top-left on the canvas */
title?: string;
/** Smaller subtitle drawn under the title */
@@ -191,6 +237,8 @@
showNow?: boolean;
/** Draw the Open-Meteo.com credit bottom-right */
showCredit?: boolean;
/** Accessible description for the interactive canvas. */
ariaLabel?: string;
/** Optional CSS class for the outer container */
class?: string;
}
@@ -203,12 +251,15 @@
pictograms = [],
windArrows = [],
highlight,
agreementStrip,
unit = '',
unitRight,
height = 300,
group,
yMin,
yMax,
yTicks,
yTickFormat,
yPadTop,
yPadBottom,
zeroBaseLeft = true,
@@ -217,11 +268,14 @@
invertRight = false,
reserveRightAxis = false,
reserveTopRows = 0,
plotInsetLeft,
plotInsetRight,
title,
subtitle,
showLegend = false,
showNow = true,
showCredit = false,
ariaLabel,
class: className = ''
}: Props = $props();
@@ -232,6 +286,8 @@
// ─── Constants ──────────────────────────────────────────────────────────────
const PAD_BOTTOM = 34;
const AGREEMENT_STRIP_HEIGHT = 10;
const AGREEMENT_STRIP_GAP = 3;
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600;
// Top icon rows (weather pictograms / wind arrows)
@@ -274,6 +330,20 @@
return color;
}
function agreementMarkerColor(point: ChartAgreementPoint): string {
if (point.wetCount === 0) return '#6b7280';
const wetShare = point.wetCount / Math.max(1, point.availableCount);
return wetShare <= 0.5 ? '#d97706' : '#2563eb';
}
function agreementFillColor(point: ChartAgreementPoint, mutedColor: string): string {
if (point.wetCount === 0) return withAlpha(mutedColor, 0.3);
const wetShare = point.wetCount / Math.max(1, point.availableCount);
const agreement = Math.abs(wetShare - 0.5) * 2;
if (wetShare <= 0.5) return withAlpha('#d97706', 0.35 + (1 - agreement) * 0.35);
return withAlpha('#2563eb', 0.3 + agreement * 0.6);
}
/**
* Light [1 2 1] smoothing pass. Cloud cover is noisy hour to hour; smoothing
* the values before interpolating gives the band a slow, rolling silhouette
@@ -368,6 +438,10 @@
const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null;
let localRange = $state<{ start: number; end: number } | null>(null);
let localHover = $state<number | null>(null);
// Pointer hover is visual only. Live-region announcements are enabled by
// keyboard navigation so mouse movement does not continuously invalidate the
// accessibility tree (and only the keyboard-focused chart announces changes).
let announceHover = $state(false);
onDestroy(() => {
if (groupName) releaseGroup(groupName);
@@ -407,12 +481,14 @@
return () => mq.removeEventListener('change', apply);
});
let isNarrow = $derived(belowDesktop || (width > 0 && width < 520));
let padLeft = $derived(isNarrow ? 26 : 60);
let padLeft = $derived(plotInsetLeft ?? (isNarrow ? (yTicks?.length ? 52 : 26) : 60));
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
// has a right axis, so a stacked row of charts share the same plot width. On
// narrow (mobile) screens we don't reserve it — the graph uses the full width
// and the right-axis labels are drawn overlaid on top instead (see below).
let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
let padRight = $derived(
plotInsetRight ?? (isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20)
);
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
// a stacked row of charts the same height even if some have fewer icon rows —
// but only on wide screens: on mobile that uniform band wastes precious
@@ -422,7 +498,10 @@
// tighter top/bottom gutters on mobile so charts don't waste vertical space
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
let padBottom = $derived(isNarrow ? 24 : PAD_BOTTOM);
let agreementStripOffset = $derived(
agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0
);
let padBottom = $derived((isNarrow ? 24 : PAD_BOTTOM) + agreementStripOffset);
let plotW = $derived(Math.max(1, width - padLeft - padRight));
let plotH = $derived(Math.max(1, height - padTop - padBottom));
@@ -536,8 +615,11 @@
}
function setHover(time: number | null): void {
if (groupState) groupState.hover = time;
else localHover = time;
if (groupState) {
if (groupState.hover !== time) groupState.hover = time;
} else if (localHover !== time) {
localHover = time;
}
}
function applyRange(start: number, end: number): void {
@@ -569,7 +651,7 @@
// The canvas alone omits the DOM overlays (weather/wind icons), the panel
// title (rendered by the parent), and the legend (an HTML row). This builds a
// standalone canvas that includes all of them so downloads look like the page.
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
const iconImageCache = new SvelteMap<string, Promise<HTMLImageElement>>();
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
const key = `${name}|${color}`;
let p = iconImageCache.get(key);
@@ -762,12 +844,23 @@
: `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`;
rows.push({ name: s.shortName ?? s.name, color: s.color, value });
}
const agreementPoint = agreementStrip?.points[hoverIdx];
if (agreementStrip && agreementPoint && agreementPoint.availableCount > 0) {
rows.push({
name: agreementStrip.label,
color: agreementMarkerColor(agreementPoint),
value: agreementStrip.format(agreementPoint)
});
}
return rows;
});
let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0);
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
let tooltipFlip = $derived(tooltipX > width * 0.55);
let crosshairVisible = $derived(
hoverIdx >= 0 && timestamps[hoverIdx] >= viewStart && timestamps[hoverIdx] <= viewEnd && width > 0
);
// ─── Pictograms (DOM overlay across the top) ─────────────────────────────────
@@ -979,11 +1072,13 @@
ctx.font = font;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (
let v = firstTick(leftScale);
v <= leftScale.max + leftScale.step / 2;
v += leftScale.step
) {
const leftTicks =
yTicks ??
Array.from(
{ length: Math.floor((leftScale.max - firstTick(leftScale)) / leftScale.step) + 1 },
(_, i) => firstTick(leftScale) + i * leftScale.step
);
for (const v of leftTicks) {
const y = yPix(v, 'left');
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
@@ -992,7 +1087,7 @@
ctx.lineTo(plotRight, y);
ctx.stroke();
ctx.fillStyle = textColor;
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
ctx.fillText(yTickFormat?.(v) ?? v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
}
// Right axis labels in the gutter (desktop only). On mobile there is no
@@ -1033,7 +1128,7 @@
ctx.fillStyle = textColor;
}
ctx.textAlign = 'center';
ctx.fillText(tick.label, x, plotBottom + 7);
ctx.fillText(tick.label, x, plotBottom + agreementStripOffset + 7);
}
// Series (clipped to the plot area)
@@ -1170,6 +1265,24 @@
continue;
}
if (s.type === 'point') {
const radius = s.pointRadius ?? 3;
ctx.fillStyle = s.color;
ctx.strokeStyle = outlineColor;
ctx.lineWidth = 1;
for (let i = 0; i < timestamps.length; i++) {
const v = s.data[i];
if (v === null || v === undefined || !isFinite(v)) continue;
const t = timestamps[i];
if (t < viewStart || t > viewEnd) continue;
ctx.beginPath();
ctx.arc(xPix(t), yPix(v, axis), radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
continue;
}
// Line series: draw fill and stroke per contiguous non-null run
// (points outside the view are handled by the clip rect). Each point
// is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set.
@@ -1406,24 +1519,42 @@
}
}
// Crosshair (snapped to the nearest timestamp)
if (hoverIdx >= 0) {
const t = timestamps[hoverIdx];
if (t >= viewStart && t <= viewEnd) {
const x = xPix(t);
ctx.strokeStyle = textColor;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(x, padTop);
ctx.lineTo(x, plotBottom);
ctx.stroke();
ctx.setLineDash([]);
}
}
ctx.restore();
if (agreementStrip) {
const stripTop = plotBottom + AGREEMENT_STRIP_GAP;
ctx.save();
ctx.beginPath();
ctx.rect(padLeft, stripTop, plotW, AGREEMENT_STRIP_HEIGHT);
ctx.clip();
for (let index = 0; index < timestamps.length; index++) {
const point = agreementStrip.points[index];
if (!point || point.availableCount === 0) continue;
const time = timestamps[index];
if (time < viewStart - interval || time > viewEnd + interval) continue;
const left = Math.max(padLeft, xPix(time - interval / 2));
const right = Math.min(plotRight, xPix(time + interval / 2));
if (right <= left) continue;
ctx.fillStyle = agreementFillColor(point, textColor);
ctx.fillRect(left, stripTop, Math.max(1, right - left + 0.5), AGREEMENT_STRIP_HEIGHT);
}
ctx.restore();
ctx.strokeStyle = withAlpha(gridColor, 0.8);
ctx.lineWidth = 1;
ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1);
ctx.fillStyle = textColor;
ctx.font = '600 9px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(
agreementStrip.label,
padLeft - 6,
stripTop + AGREEMENT_STRIP_HEIGHT / 2,
Math.max(24, padLeft - 10)
);
}
// Axis unit labels
ctx.font = font;
ctx.textBaseline = 'alphabetic';
@@ -1515,13 +1646,47 @@
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
let selStartX = 0; // drag-to-zoom anchor (canvas-local px)
let hoverFrame = 0;
let queuedClientX = 0;
let queuedHoverAction: 'none' | 'position' | 'clear' = 'none';
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x));
const updateHover = (e: PointerEvent): void => {
const t = pixToTime(localX(e));
setHover(t >= viewStart && t <= viewEnd ? t : null);
const scheduleHoverFrame = (): void => {
if (hoverFrame !== 0) return;
hoverFrame = requestAnimationFrame(() => {
hoverFrame = 0;
const action = queuedHoverAction;
queuedHoverAction = 'none';
if (action === 'clear') {
setHover(null);
return;
}
if (action !== 'position') return;
// Geometry reads and timestamp lookup happen once per animation frame,
// even when a high-polling pointer emits many events between frames.
const t = pixToTime(queuedClientX - el.getBoundingClientRect().left);
const time =
t >= viewStart && t <= viewEnd && timestamps.length > 0
? timestamps[nearestIndex(timestamps, t)]
: null;
setHover(time);
});
};
const scheduleHoverPosition = (clientX: number): void => {
if (announceHover) announceHover = false;
queuedClientX = clientX;
queuedHoverAction = 'position';
scheduleHoverFrame();
};
const scheduleHoverClear = (): void => {
if (announceHover) announceHover = false;
queuedHoverAction = 'clear';
scheduleHoverFrame();
};
const onPointerDown = (e: PointerEvent): void => {
@@ -1533,7 +1698,7 @@
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
panStart = null;
gesture = 'pinch';
setHover(null);
scheduleHoverClear();
return;
}
@@ -1573,7 +1738,7 @@
const x = clampPlotX(localX(e));
if (dragSelect || Math.abs(x - selStartX) >= 3) {
dragSelect = { x0: selStartX, x1: x };
setHover(null);
scheduleHoverClear();
}
return;
}
@@ -1603,7 +1768,7 @@
return;
}
if (pointers.size <= 1) updateHover(e);
if (pointers.size <= 1) scheduleHoverPosition(e.clientX);
};
const onPointerUp = (e: PointerEvent): void => {
@@ -1622,11 +1787,11 @@
touchStart = null;
gesture = 'none';
}
if (e.pointerType !== 'mouse') setHover(null);
if (e.pointerType !== 'mouse') scheduleHoverClear();
};
const onPointerLeave = (): void => {
if (pointers.size === 0) setHover(null);
if (pointers.size === 0) scheduleHoverClear();
};
const onWheel = (e: WheelEvent): void => {
@@ -1660,6 +1825,7 @@
el.removeEventListener('pointerleave', onPointerLeave);
el.removeEventListener('wheel', onWheel);
el.removeEventListener('dblclick', onDblClick);
if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
};
});
@@ -1667,6 +1833,29 @@
if (legendHidden.has(name)) legendHidden.delete(name);
else legendHidden.add(name);
}
function handleKeydown(event: KeyboardEvent): void {
if (timestamps.length === 0) return;
const current = hoverTime ?? viewStart;
const index = nearestIndex(timestamps, current);
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const delta = event.key === 'ArrowLeft' ? -1 : 1;
const next = Math.max(0, Math.min(timestamps.length - 1, index + delta));
announceHover = true;
setHover(timestamps[next]);
event.preventDefault();
} else if (event.key === '+' || event.key === '=') {
zoomAt(current, 1 / 1.3);
event.preventDefault();
} else if (event.key === '-' || event.key === '_') {
zoomAt(current, 1.3);
event.preventDefault();
} else if (event.key === 'Escape') {
announceHover = false;
setViewRange(null);
setHover(null);
}
}
</script>
<div bind:this={containerEl} class="relative w-full select-none {className}">
@@ -1676,7 +1865,16 @@
class="block w-full"
style:height="{height}px"
style:touch-action="pan-y"
tabindex="0"
aria-label={ariaLabel}
onkeydown={handleKeydown}
></canvas>
<p class="sr-only" aria-live="polite">
{#if announceHover && tooltipVisible}
{formatZoned(new Date(timestamps[hoverIdx] * 1000), timezone, 'EEE d MMM HH:mm')}:
{tooltipRows.map((row) => `${row.name} ${row.value}`).join(', ')}
{/if}
</p>
<!-- Drag-to-zoom selection rectangle -->
{#if dragSelect}
@@ -1685,7 +1883,7 @@
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
style:top="{padTop}px"
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
style:height="{plotH}px"
style:height="{plotH + agreementStripOffset}px"
></div>
{/if}
@@ -1738,6 +1936,17 @@
</div>
{/if}
<!-- Hover is intentionally a DOM overlay: moving the crosshair must not
repaint the expensive base canvas or every synchronized chart. -->
{#if crosshairVisible}
<div
class="pointer-events-none absolute left-0 z-[15] border-l border-dashed border-muted-foreground/70 will-change-transform"
style:transform="translateX({tooltipX}px)"
style:top="{padTop}px"
style:height="{plotH + agreementStripOffset}px"
></div>
{/if}
{#if tooltipVisible}
<div
class="pointer-events-none absolute z-20 rounded-md border border-border bg-popover px-3 py-2 text-xs whitespace-nowrap text-popover-foreground shadow-md"
+9 -10
View File
@@ -33,19 +33,19 @@ export const CHART_COLORS = {
// ─── Utility: Detect column-type variables ───────────────────────────────────
/** Units that should be rendered as bar/column charts instead of lines. */
const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
const COLUMN_UNITS = new Set(['mm', 'cm', 'in', 'inch', 'MJ/m²']);
/**
* Returns true if the given unit should be rendered as a bar chart.
*/
export function isColumnUnit(unit: string): boolean {
return COLUMN_UNITS.has(unit);
return COLUMN_UNITS.has(unit.trim());
}
// ─── Data Processing Helpers ─────────────────────────────────────────────────
export interface AverageResult {
average: number[];
average: (number | null)[];
averageCount: number[];
}
@@ -62,7 +62,7 @@ export function calculateAverage(
variable: string,
timeLength: number
): AverageResult {
const average = new Array<number>(timeLength).fill(0);
const totals = new Array<number>(timeLength).fill(0);
const averageCount = new Array<number>(timeLength).fill(0);
for (const [model, values] of Object.entries(hourlyData)) {
@@ -71,18 +71,17 @@ export function calculateAverage(
for (const [index, val] of (values as number[]).entries()) {
if (val !== null && val !== undefined && isFinite(val)) {
average[index] += val;
if (index >= timeLength) continue;
totals[index] += val;
averageCount[index]++;
}
}
}
// Finalize average values
for (let i = 0; i < timeLength; i++) {
if (averageCount[i] > 0) {
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
}
}
const average = totals.map((total, i) =>
averageCount[i] > 0 ? Math.round((total / averageCount[i]) * 10) / 10 : null
);
return { average, averageCount };
}
+7 -1
View File
@@ -8,10 +8,16 @@
export {
default as CanvasChart,
setGroupHover,
setGroupRange,
registerGroupMember,
groupRange,
groupHover
} from './CanvasChart.svelte';
export type { ChartSeries } from './CanvasChart.svelte';
export type {
ChartAgreementPoint,
ChartAgreementStrip,
ChartSeries
} from './CanvasChart.svelte';
export { buildDaylightBands } from './bands';
export type { DaylightBand } from './bands';
+15 -9
View File
@@ -18,14 +18,15 @@
{/snippet}
</ChartToolbar>
-->
<script module lang="ts">
<script lang="ts">
import * as m from '$lib/paraglide/messages';
export type { ExportableChart } from './downloadChartsPng';
</script>
<script lang="ts">
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
import {
type ChartDownloadOptions,
type ChartExportItem,
type ExportableChart,
downloadChartsPng
} from './downloadChartsPng';
import type { Snippet } from 'svelte';
@@ -33,9 +34,11 @@
interface Props {
/** Chart components available for download (undefined entries are skipped) */
charts?: Array<ExportableChart | undefined | null>;
charts?: Array<ChartExportItem | ExportableChart | undefined | null>;
/** Base file name for downloaded images (without extension) */
fileName?: string;
/** Optional title and shared legend drawn into the combined PNG. */
exportOptions?: ChartDownloadOptions;
/** Optional CSS class for the outer container */
class?: string;
/** Slot for additional controls (switches, checkboxes, etc.) */
@@ -45,6 +48,7 @@
let {
charts = [],
fileName = 'drizzli-chart',
exportOptions,
class: className = '',
controls
}: Props = $props();
@@ -55,7 +59,9 @@
// ─── Computed ───────────────────────────────────────────────────────────────
let hasCharts = $derived(charts.some((chart) => chart != null));
let hasCharts = $derived(
charts.some((item) => item != null && ('chart' in item ? item.chart != null : true))
);
// ─── Download ───────────────────────────────────────────────────────────────
@@ -63,7 +69,7 @@
if (!hasCharts || downloading) return;
downloading = true;
try {
await downloadChartsPng(charts, fileName);
await downloadChartsPng(charts, fileName, exportOptions);
} finally {
setTimeout(() => {
downloading = false;
+102 -6
View File
@@ -17,6 +17,17 @@ export interface ChartExportItem {
title?: string;
}
export interface ExportLegendItem {
name: string;
color: string;
style?: 'point' | 'line' | 'dashed' | 'bar';
}
export interface ChartDownloadOptions {
title?: string;
legend?: ExportLegendItem[];
}
/** Resolves the page background so exports match the current theme. */
function exportBackground(): string {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
@@ -47,18 +58,60 @@ function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartE
*/
export async function downloadChartsPng(
charts: Array<ChartExportItem | ExportableChart | null | undefined>,
fileName: string
fileName: string,
options: ChartDownloadOptions = {}
): Promise<void> {
const items = charts.map(toItem).filter((it) => it.chart != null);
const items = charts
.map(toItem)
.filter(
(it): it is ChartExportItem & { chart: ExportableChart } =>
typeof it.chart?.getExportImage === 'function'
);
if (items.length === 0) return;
const canvases = (
await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title })))
).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0);
// A supplementary panel must never prevent the remaining charts from being
// downloaded. Invalid adapters are filtered above; rejected renders are
// isolated here instead of aborting the complete Promise.all chain.
const rendered = await Promise.allSettled(
items.map((it) => it.chart.getExportImage({ title: it.title }))
);
const canvases = rendered.flatMap((result) =>
result.status === 'fulfilled' &&
result.value != null &&
result.value.width > 0 &&
result.value.height > 0
? [result.value]
: []
);
if (canvases.length === 0) return;
const maxWidth = Math.max(...canvases.map((c) => c.width));
const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
const dpr = window.devicePixelRatio || 1;
const padding = 12 * dpr;
const gap = 14 * dpr;
const rowHeight = 22 * dpr;
const measure = document.createElement('canvas').getContext('2d');
const legendRows: ExportLegendItem[][] = [];
if (measure && options.legend?.length) {
measure.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
let row: ExportLegendItem[] = [];
let rowWidth = 0;
for (const item of options.legend) {
const width = 18 * dpr + measure.measureText(item.name).width;
if (row.length > 0 && rowWidth + gap + width > maxWidth - padding * 2) {
legendRows.push(row);
row = [];
rowWidth = 0;
}
row.push(item);
rowWidth += (rowWidth > 0 ? gap : 0) + width;
}
if (row.length > 0) legendRows.push(row);
}
const headerHeight =
(options.title ? 34 * dpr : 0) +
(legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0);
const totalHeight = headerHeight + canvases.reduce((sum, c) => sum + c.height, 0);
const canvas = document.createElement('canvas');
canvas.width = maxWidth;
@@ -70,7 +123,50 @@ export async function downloadChartsPng(
ctx.fillStyle = exportBackground();
ctx.fillRect(0, 0, maxWidth, totalHeight);
const styles = getComputedStyle(document.documentElement);
const foreground = styles.getPropertyValue('--foreground').trim() || '#1f2937';
const muted = styles.getPropertyValue('--muted-foreground').trim() || '#6b7280';
let y = 0;
if (options.title) {
ctx.fillStyle = foreground;
ctx.font = `600 ${16 * dpr}px system-ui, -apple-system, sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillText(options.title, padding, 17 * dpr);
y += 34 * dpr;
}
if (legendRows.length > 0) {
ctx.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
ctx.textBaseline = 'middle';
for (const row of legendRows) {
let x = padding;
for (const item of row) {
const markerWidth = 12 * dpr;
ctx.strokeStyle = item.color;
ctx.fillStyle = item.color;
ctx.lineWidth = 2 * dpr;
ctx.setLineDash(item.style === 'dashed' ? [4 * dpr, 3 * dpr] : []);
if (item.style === 'point') {
ctx.beginPath();
ctx.arc(x + markerWidth / 2, y + rowHeight / 2, 3 * dpr, 0, Math.PI * 2);
ctx.fill();
} else if (item.style === 'bar') {
ctx.fillRect(x + 3 * dpr, y + 5 * dpr, 6 * dpr, 12 * dpr);
} else {
ctx.beginPath();
ctx.moveTo(x, y + rowHeight / 2);
ctx.lineTo(x + markerWidth, y + rowHeight / 2);
ctx.stroke();
}
ctx.setLineDash([]);
x += markerWidth + 6 * dpr;
ctx.fillStyle = muted;
ctx.fillText(item.name, x, y + rowHeight / 2);
x += ctx.measureText(item.name).width + gap;
}
y += rowHeight;
}
y += 8 * dpr;
}
for (const c of canvases) {
ctx.drawImage(c, 0, y);
y += c.height;
+7 -1
View File
@@ -9,4 +9,10 @@
export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.svelte';
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
export {
downloadChartsPng,
type ChartDownloadOptions,
type ChartExportItem,
type ExportableChart,
type ExportLegendItem
} from './downloadChartsPng';
+34 -133
View File
@@ -10,6 +10,7 @@
* - Efficient binary protobuf transport instead of JSON
* - Consistent timestamp and unit handling
*/
import { Model } from '@openmeteo/sdk/model';
import { Unit } from '@openmeteo/sdk/unit';
import { fetchWeatherApi } from 'openmeteo';
@@ -241,7 +242,10 @@ export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams {
}
export interface ModelSeriesData {
modelName: string;
/** Stable identifier requested by the UI. */
modelId: string;
/** Concrete model identifier reported by the API (useful for seamless/best-match requests). */
resolvedModelId: string;
variables: Record<string, number[]>;
}
@@ -535,7 +539,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
* compatible with existing chart utilities.
*/
export async function fetchModelComparison(
params: ModelCompareParams
params: ModelCompareParams,
options: { signal?: AbortSignal } = {}
): Promise<ModelCompareResult> {
const forecastApiParams: Record<string, string | number | undefined> = {
latitude: String(params.latitude),
@@ -549,14 +554,28 @@ export async function fetchModelComparison(
timezone: params.timezone
};
const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
const responses = await fetchWeatherApi(
FORECAST_URL,
forecastApiParams,
undefined,
undefined,
undefined,
{ signal: options.signal }
);
if (responses.length === 0) throw new Error('The weather service returned no model data.');
if (responses.length !== params.models.length) {
throw new Error(
`The weather service returned ${responses.length} model responses for ${params.models.length} requested models.`
);
}
// With multiple models, we get one response per model
const firstResponse = responses[0];
const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
const timezone = firstResponse.timezone() ?? params.timezone ?? 'UTC';
const hourlyBlock = firstResponse.hourly()!;
const hourlyBlock = firstResponse.hourly();
if (!hourlyBlock) throw new Error('The weather service returned no hourly model data.');
const timestamps = getTimestamps(hourlyBlock);
// Extract sunrise/sunset from the first response's daily block
@@ -586,16 +605,22 @@ export async function fetchModelComparison(
);
hourlyFlat['time'] = timeInUnixSeconds;
for (const response of responses) {
for (const [responseIndex, response] of responses.entries()) {
const modelHourly = response.hourly();
if (!modelHourly) continue;
// Determine model name from the response
// The multi-model endpoint preserves request order and returns one response
// per requested model, including unavailable regional models. Keep that
// requested id as the stable UI key. The concrete id reported by Open-Meteo
// is metadata only: seamless and best-match requests may resolve to a
// different underlying domain.
const modelEnum = response.model();
const modelName = modelEnumToString(modelEnum);
const resolvedModelId = Model[modelEnum] ?? `model_${modelEnum}`;
const modelId = params.models[responseIndex] ?? resolvedModelId;
const modelData: ModelSeriesData = {
modelName,
modelId,
resolvedModelId,
variables: {}
};
@@ -608,7 +633,7 @@ export async function fetchModelComparison(
modelData.variables[varName] = values;
// Build flat key like "temperature_2m_icon_seamless"
const flatKey = `${varName}_${modelName}`;
const flatKey = `${varName}_${modelId}`;
hourlyFlat[flatKey] = values;
// Record unit
@@ -786,130 +811,6 @@ export async function fetchEnsembleForecast(
};
}
// ─── Model Enum Mapping ─────────────────────────────────────────────────────────
/**
* Maps the SDK Model enum integer to a string model name.
* This table must stay in sync with the @openmeteo/sdk Model enum.
*/
function modelEnumToString(modelEnum: number): string {
const modelMap: Record<number, string> = {
0: 'undefined',
1: 'best_match',
2: 'gfs_seamless',
3: 'gfs_global',
4: 'gfs_hrrr',
5: 'meteofrance_seamless',
6: 'meteofrance_arpege_seamless',
7: 'meteofrance_arpege_world',
8: 'meteofrance_arpege_europe',
9: 'meteofrance_arome_seamless',
10: 'meteofrance_arome_france',
11: 'meteofrance_arome_france_hd',
12: 'jma_seamless',
13: 'jma_msm',
14: 'jms_gsm',
15: 'jma_gsm',
16: 'gem_seamless',
17: 'gem_global',
18: 'gem_regional',
19: 'gem_hrdps_continental',
20: 'icon_seamless',
21: 'icon_global',
22: 'icon_eu',
23: 'icon_d2',
24: 'ecmwf_ifs04',
25: 'metno_nordic',
26: 'era5_seamless',
27: 'era5',
28: 'cerra',
29: 'era5_land',
30: 'ecmwf_ifs',
31: 'gwam',
32: 'ewam',
33: 'glofas_seamless_v3',
34: 'glofas_forecast_v3',
35: 'glofas_consolidated_v3',
36: 'glofas_seamless_v4',
37: 'glofas_forecast_v4',
38: 'glofas_consolidated_v4',
39: 'gfs025',
40: 'gfs05',
41: 'CMCC_CM2_VHR4',
42: 'FGOALS_f3_H_highresSST',
43: 'FGOALS_f3_H',
44: 'HiRAM_SIT_HR',
45: 'MRI_AGCM3_2_S',
46: 'EC_Earth3P_HR',
47: 'MPI_ESM1_2_XR',
48: 'NICAM16_8S',
49: 'cams_europe',
50: 'cams_global',
51: 'cfsv2',
52: 'era5_ocean',
53: 'cma_grapes_global',
54: 'bom_access_global',
55: 'bom_access_global_ensemble',
56: 'arpae_cosmo_seamless',
57: 'arpae_cosmo_2i',
58: 'arpae_cosmo_2i_ruc',
59: 'arpae_cosmo_5m',
60: 'ecmwf_ifs025',
61: 'ecmwf_aifs025',
62: 'gfs013',
63: 'gfs_graphcast025',
64: 'ecmwf_wam025',
65: 'meteofrance_wave',
66: 'meteofrance_currents',
67: 'ecmwf_wam025_ensemble',
68: 'ncep_gfswave025',
69: 'ncep_gefswave025',
70: 'knmi_seamless',
71: 'knmi_harmonie_arome_europe',
72: 'knmi_harmonie_arome_netherlands',
73: 'dmi_seamless',
74: 'dmi_harmonie_arome_europe',
75: 'metno_seamless',
76: 'era5_ensemble',
77: 'ecmwf_ifs_analysis',
78: 'ecmwf_ifs_long_window',
79: 'ecmwf_ifs_analysis_long_window',
80: 'ukmo_global_deterministic_10km',
81: 'ukmo_uk_deterministic_2km',
82: 'ukmo_seamless',
83: 'ncep_gfswave016',
84: 'ncep_nbm_conus',
85: 'ukmo_global_ensemble_20km',
86: 'ecmwf_aifs025_single',
87: 'jma_jaxa_himawari',
88: 'eumetsat_sarah3',
89: 'eumetsat_lsa_saf_msg',
90: 'eumetsat_lsa_saf_iodc',
91: 'satellite_radiation_seamless',
92: 'kma_gdps',
93: 'kma_ldps',
94: 'kma_seamless',
95: 'italia_meteo_arpae_icon_2i',
96: 'ukmo_uk_ensemble_2km',
97: 'meteofrance_arome_france_hd_15min',
98: 'meteofrance_arome_france_15min',
99: 'meteoswiss_icon_ch1',
100: 'meteoswiss_icon_ch2',
101: 'meteoswiss_icon_ch1_ensemble',
102: 'meteoswiss_icon_ch2_ensemble',
103: 'meteoswiss_icon_seamless',
104: 'ncep_nam_conus',
105: 'icon_d2_ruc',
106: 'ecmwf_seas5',
107: 'ecmwf_ec46',
108: 'ecmwf_seasonal_seamless',
109: 'ecmwf_ifs_seamless',
110: 'jma_jaxa_mtg_fci',
111: 'gem_hrdps_west'
};
return modelMap[modelEnum] ?? `model_${modelEnum}`;
}
// ─── Historical (Archive) Types ─────────────────────────────────────────────
export interface HistoricalDailyData {
+3 -4
View File
@@ -9,7 +9,7 @@
* automatically once the maps app ships seamless support; until then they fall
* back to their widest-coverage member (a regional member could be entirely
* off-screen). Models without an entry are tried under their own id; models
* the map does not serve at all (best_match, bom, google, UKMO ensembles)
* the map does not serve at all (best_match, google, UKMO ensembles)
* resolve to null.
*/
const modelDomainCandidates: Record<string, string[]> = {
@@ -46,9 +46,8 @@ const modelDomainCandidates: Record<string, string[]> = {
// MeteoSwiss (CH2 covers a wider area than CH1)
meteoswiss_icon_seamless: ['meteoswiss_icon_ch2'],
// KMA Korea (kma_ldps is not served by the maps project)
kma_seamless: ['kma_gdps'],
kma_ldps: ['kma_gdps'],
// CHMI Czech Republic (Central Europe is the widest native domain)
chmi_aladin_seamless: ['chmi_aladin_seamless', 'chmi_aladin_central_europe_2km'],
// JMA Japan
jma_seamless: ['jma_seamless', 'jma_gsm'],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,233 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages';
interface Option {
value: string;
label: string;
metadata?: string;
}
interface OptionGroup {
value: string;
label: string;
options: Option[];
}
interface Props {
open: boolean;
mode: 'models' | 'variables';
groups: OptionGroup[];
selected: string[];
dirty: boolean;
onToggle: (value: string) => void;
onToggleGroup: (values: string[], select: boolean) => void;
onApply: () => void;
onClose: () => void;
onRestoreDefaults?: () => void;
}
let {
open,
mode,
groups,
selected,
dirty,
onToggle,
onToggleGroup,
onApply,
onClose,
onRestoreDefaults
}: Props = $props();
let search = $state('');
let onlySelected = $state(false);
let normalizedSearch = $derived(search.trim().toLocaleLowerCase());
let visibleGroups = $derived.by(() =>
groups.flatMap((group) => {
const options = group.options.filter((option) => {
if (onlySelected && !selected.includes(option.value)) return false;
if (!normalizedSearch) return true;
return `${group.label} ${option.label} ${option.value} ${option.metadata ?? ''}`
.toLocaleLowerCase()
.includes(normalizedSearch);
});
return options.length > 0 ? [{ ...group, options }] : [];
})
);
$effect(() => {
if (open) return;
search = '';
onlySelected = false;
});
function groupSelection(group: OptionGroup): { count: number; all: boolean; partial: boolean } {
const count = group.options.filter((option) => selected.includes(option.value)).length;
return { count, all: count === group.options.length, partial: count > 0 && count < group.options.length };
}
function applyAndClose(): void {
onApply();
onClose();
}
</script>
<Dialog.Dialog {open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<Dialog.DialogContent
showCloseButton={false}
class="top-0 right-0 bottom-0 left-auto flex h-dvh w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 sm:w-[640px] sm:max-w-[90vw] sm:border-l"
>
<header class="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<Dialog.DialogTitle class="text-lg font-bold">
{mode === 'models' ? m.compare_edit_models() : m.compare_edit_variables()}
</Dialog.DialogTitle>
<Dialog.DialogDescription class="mt-0.5 text-xs">
{mode === 'models'
? m.compare_edit_models_description()
: m.compare_edit_variables_description()}
</Dialog.DialogDescription>
</div>
<button
type="button"
class="flex size-9 shrink-0 items-center justify-center rounded-lg text-xl text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={onClose}
aria-label={m.action_close()}
>
<span aria-hidden="true">×</span>
</button>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<Input
type="search"
bind:value={search}
placeholder={mode === 'models'
? m.compare_search_models()
: m.compare_search_variables()}
aria-label={mode === 'models'
? m.compare_search_models()
: m.compare_search_variables()}
/>
<div
class="flex min-h-9 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border px-3 text-xs font-semibold hover:bg-muted"
>
<Checkbox id="drawer_{mode}_only_selected" bind:checked={onlySelected} />
<Label for="drawer_{mode}_only_selected" class="cursor-pointer text-xs">
{m.compare_only_selected()}
</Label>
</div>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-5">
{#if visibleGroups.length > 0}
<div class="grid items-start gap-3 sm:grid-cols-2">
{#each visibleGroups as visibleGroup (visibleGroup.value)}
{@const fullGroup = groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup}
{@const selection = groupSelection(fullGroup)}
<section class="overflow-hidden rounded-xl border border-border/70 bg-card">
<div class="flex min-h-11 items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-2">
<Checkbox
id="drawer_{mode}_group_{fullGroup.value}"
checked={selection.all}
indeterminate={selection.partial}
onCheckedChange={() =>
onToggleGroup(
fullGroup.options.map((option) => option.value),
!selection.all
)}
/>
<Label
for="drawer_{mode}_group_{fullGroup.value}"
class="min-w-0 flex-1 cursor-pointer text-sm font-bold"
>
{fullGroup.label}
</Label>
<span class="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{selection.count}/{fullGroup.options.length}
</span>
<button
type="button"
class="shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold text-primary hover:bg-primary/10"
onclick={() =>
onToggleGroup(
fullGroup.options.map((option) => option.value),
!selection.all
)}
>
{selection.all ? m.compare_clear_group() : m.compare_select_group()}
</button>
</div>
<div class="p-2">
{#each visibleGroup.options as option (option.value)}
<div class="flex min-h-10 items-center rounded-lg px-1.5 hover:bg-muted/60">
<Checkbox
id="drawer_{mode}_{option.value}"
checked={selected.includes(option.value)}
onCheckedChange={() => onToggle(option.value)}
/>
<Label
for="drawer_{mode}_{option.value}"
class="min-w-0 flex-1 cursor-pointer py-2 pl-2"
>
<span class="block text-sm font-medium">{option.label}</span>
{#if option.metadata}
<span class="block text-[10px] text-muted-foreground">
{option.metadata}
</span>
{/if}
</Label>
</div>
{/each}
</div>
</section>
{/each}
</div>
{:else}
<div class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
{m.compare_no_matching_options()}
</div>
{/if}
</div>
<footer class="shrink-0 border-t border-border bg-background px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
{#if mode === 'variables' && onRestoreDefaults}
<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={onRestoreDefaults}
>
{m.compare_restore_defaults()}
</button>
{/if}
</div>
<div class="flex gap-2">
<button
type="button"
class="min-h-10 rounded-lg border border-border px-4 py-2 text-sm font-semibold hover:bg-muted"
onclick={onClose}
>
{m.action_close()}
</button>
<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:opacity-50"
disabled={!dirty}
onclick={applyAndClose}
>
{m.compare_apply_selection()}
</button>
</div>
</div>
</footer>
</Dialog.DialogContent>
</Dialog.Dialog>
@@ -1,154 +1,760 @@
<script lang="ts">
import { formatZoned, getZonedHour } from '$lib/utils/date';
import { onDestroy, onMount } from 'svelte';
import {
groupHover,
groupRange,
registerGroupMember,
setGroupHover,
setGroupRange
} from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { formatZoned } from '$lib/utils/date';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherIconName, hasWeatherIcon } from '../../utils/weather-codes';
import { modelColor, modelLabel } from './comparison';
import type { ModelSeriesData } from '$lib/services/weather';
interface Props {
timestamps: number[];
hourlyFlat: Record<string, number[]>;
models: string[];
models: ModelSeriesData[];
modelOrder: string[];
sunrise: number[];
sunset: number[];
timezone: string;
group: string;
plotInsetLeft: number;
plotInsetRight: number;
registerExporter?: (exporter: TimelineExporter | null) => void;
}
let { timestamps, hourlyFlat, models, sunrise, sunset, timezone }: Props = $props();
let hourlyInterval = $state<1 | 3>(3);
let filteredIndices = $derived(
timestamps.reduce<number[]>((acc, ts, i) => {
if (hourlyInterval === 1 || getZonedHour(new Date(ts), timezone) % 3 === 0) {
acc.push(i);
interface TimelineExporter {
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
}
return acc;
}, [])
interface TimelinePoint {
index: number;
time: number;
x: number;
hour: string;
date: string;
}
interface DaySeparator {
x: number;
}
interface SunlightSegment {
x1: number;
x2: number;
}
interface TooltipRow {
modelId: string;
label: string;
color: string;
condition: string;
cloudCover: number | null;
}
let {
timestamps,
models,
modelOrder,
sunrise,
sunset,
timezone,
group,
plotInsetLeft,
plotInsetRight,
registerExporter
}: Props = $props();
const HOUR = 3600;
const MIN_SPAN = 2 * HOUR;
const HEADER_H = 38;
const ROW_H = 38;
const FOOTER_H = 5;
const ICON_SIZE = 20;
const ICON_BOX_WIDTH = 20;
const ICON_BOX_HEIGHT = 30;
const MIN_ICON_GAP = 20;
const SUNLIGHT_STRIP_H = 4;
const EXPORT_TITLE_H = 30;
const SAMPLE_STEPS = [1, 2, 3, 4, 6, 8, 12, 24] as const;
let containerEl: HTMLDivElement | undefined = $state();
let canvasEl: HTMLCanvasElement | undefined = $state();
let width = $state(0);
let themeVersion = $state(0);
let dragSelect = $state<{ x0: number; x1: number } | null>(null);
let screenRenderVersion = 0;
let timestampsSec = $derived(timestamps.map((timestamp) => timestamp / 1000));
let displayModels = $derived(
models.filter((model) => model.variables.weather_code?.some(hasWeatherIcon))
);
let sharedRange = $derived(groupRange(group));
let sharedHover = $derived(groupHover(group));
let tMin = $derived(timestampsSec[0] ?? 0);
let tMax = $derived(
timestampsSec.length > 1 ? timestampsSec[timestampsSec.length - 1] : tMin + HOUR
);
let viewStart = $derived(sharedRange ? Math.max(tMin, sharedRange.start) : tMin);
let viewEnd = $derived(
sharedRange ? Math.max(viewStart + MIN_SPAN / 2, Math.min(tMax, sharedRange.end)) : tMax
);
let zoomed = $derived(sharedRange !== null && viewEnd - viewStart < tMax - tMin);
let plotWidth = $derived(Math.max(1, width - plotInsetLeft - plotInsetRight));
let height = $derived(HEADER_H + displayModels.length * ROW_H + FOOTER_H);
function checkNewDay(i: number, ts: number): boolean {
if (i === 0) return false;
const prevTs = timestamps[filteredIndices[i - 1]];
return (
formatZoned(new Date(ts), timezone, 'd') !== formatZoned(new Date(prevTs), timezone, 'd')
function xForTime(time: number): number {
return plotInsetLeft + ((time - viewStart) / Math.max(1, viewEnd - viewStart)) * plotWidth;
}
function timeForX(x: number): number {
return viewStart + ((x - plotInsetLeft) / Math.max(1, plotWidth)) * (viewEnd - viewStart);
}
function clampPlotX(x: number): number {
return Math.max(plotInsetLeft, Math.min(width - plotInsetRight, x));
}
function applyRange(start: number, end: number): void {
const fullSpan = tMax - tMin;
const span = Math.min(Math.max(end - start, MIN_SPAN), fullSpan);
if (span >= fullSpan) {
setGroupRange(group, null);
return;
}
const boundedStart = Math.max(tMin, Math.min(start, tMax - span));
setGroupRange(group, { start: boundedStart, end: boundedStart + span });
}
function zoomAt(centerTime: number, factor: number): void {
const span = viewEnd - viewStart;
const nextSpan = span * factor;
const fraction = (centerTime - viewStart) / Math.max(1, span);
applyRange(
centerTime - fraction * nextSpan,
centerTime + (1 - fraction) * nextSpan
);
}
/**
* Determines if it's daytime for each timestamp based on sunrise/sunset data.
*/
let allDaytimeFlags = $derived(
timestamps.map((ts) => {
const tsS = ts / 1000;
function nearestIndex(values: number[], target: number): number {
let low = 0;
let high = values.length - 1;
while (low < high) {
const middle = (low + high) >> 1;
if (values[middle] < target) low = middle + 1;
else high = middle;
}
if (
low > 0 &&
Math.abs(values[low - 1] - target) <= Math.abs(values[low] - target)
) {
return low - 1;
}
return low;
}
function isDaytime(time: number): boolean {
for (let i = 0; i < sunrise.length; i++) {
const s = sunrise[i];
const e = sunset[i];
// Between sunrise and sunset of the same day
if (tsS >= s && tsS < e) return true;
// Between sunset of day i and sunrise of day i+1 (night)
const nextS = sunrise[i + 1] || Infinity;
if (tsS >= e && tsS < nextS) return false;
if (time >= sunrise[i] && time < sunset[i]) return true;
const nextSunrise = sunrise[i + 1] ?? Infinity;
if (time >= sunset[i] && time < nextSunrise) return false;
}
return sunrise.length === 0 || time >= sunrise[0];
}
let sampleStepHours = $derived.by(() => {
const pixelsPerHour = plotWidth / Math.max(1, (viewEnd - viewStart) / HOUR);
return SAMPLE_STEPS.find((step) => step * pixelsPerHour >= MIN_ICON_GAP) ?? 24;
});
let visiblePoints = $derived.by((): TimelinePoint[] => {
const points: TimelinePoint[] = [];
const edgeInset = ICON_SIZE / 2 + 1;
const firstValidIndex = timestampsSec.findIndex((time) => {
const x = xForTime(time);
return time >= viewStart && time <= viewEnd && x >= plotInsetLeft + edgeInset;
});
if (firstValidIndex < 0) return points;
const firstTime = timestampsSec[firstValidIndex];
const canvasRight = width - edgeInset;
let previousDay = '';
for (let index = firstValidIndex; index < timestampsSec.length; index++) {
const time = timestampsSec[index];
if (time < viewStart || time > viewEnd) continue;
const x = xForTime(time);
if (x > canvasRight) break;
const hoursFromFirst = Math.round((time - firstTime) / HOUR);
if (hoursFromFirst % sampleStepHours !== 0) continue;
const date = new Date(timestamps[index]);
const day = formatZoned(date, timezone, 'yyyy-MM-dd');
points.push({
index,
time,
x,
hour: formatZoned(date, timezone, 'HH'),
date: day === previousDay ? '' : formatZoned(date, timezone, 'EEE d')
});
previousDay = day;
}
return points;
});
let daySeparators = $derived.by((): DaySeparator[] => {
const separators: DaySeparator[] = [];
let previousDay = '';
for (let index = 0; index < timestampsSec.length; index++) {
const time = timestampsSec[index];
if (time < viewStart || time > viewEnd) continue;
const day = formatZoned(new Date(timestamps[index]), timezone, 'yyyy-MM-dd');
if (previousDay && day !== previousDay) separators.push({ x: xForTime(time) });
previousDay = day;
}
return separators;
});
let sunlightSegments = $derived.by((): SunlightSegment[] => {
const segments: SunlightSegment[] = [];
for (let index = 0; index < sunrise.length; index++) {
const start = Math.max(viewStart, sunrise[index]);
const end = Math.min(viewEnd, sunset[index] ?? sunrise[index]);
if (end <= start) continue;
segments.push({ x1: xForTime(start), x2: xForTime(end) });
}
return segments;
});
let hoverX = $derived(
sharedHover !== null && sharedHover >= viewStart && sharedHover <= viewEnd
? xForTime(sharedHover)
: null
);
let hoverIndex = $derived(
sharedHover !== null && hoverX !== null && timestampsSec.length > 0
? nearestIndex(timestampsSec, sharedHover)
: -1
);
function conditionLabel(code: number): string {
if (code === 0) return m.cond_clear();
if (code === 1 || code === 2) return m.cond_fair();
if (code === 3) return m.cond_cloudy();
if (code === 45 || code === 48) return m.cond_fog();
if ([51, 53, 55, 56, 57].includes(code)) return m.cond_drizzle();
if ([71, 73, 75, 77, 85, 86].includes(code)) return m.cond_snow();
if ([95, 96, 99].includes(code)) return m.cond_thunder();
return m.cond_rain();
}
let tooltipRows = $derived.by((): TooltipRow[] => {
if (hoverIndex < 0) return [];
const rows: TooltipRow[] = [];
for (const model of displayModels) {
const code = model.variables.weather_code?.[hoverIndex];
if (!hasWeatherIcon(code)) continue;
const cloudCover = model.variables.cloud_cover?.[hoverIndex];
rows.push({
modelId: model.modelId,
label: modelLabel(model.modelId),
color: modelColor(model.modelId, modelOrder),
condition: conditionLabel(code),
cloudCover:
cloudCover !== undefined && Number.isFinite(cloudCover)
? Math.round(Math.max(0, Math.min(100, cloudCover)))
: null
});
}
return rows;
});
let tooltipVisible = $derived(hoverIndex >= 0 && tooltipRows.length > 0 && hoverX !== null);
let tooltipFlip = $derived((hoverX ?? 0) > width * 0.55);
let hoverFrame = 0;
let queuedClientX = 0;
const pointers = new Map<number, { x: number; y: number }>();
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
let selectionStartX = 0;
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
let panStart: { x: number; start: number; end: number } | null = null;
let pinchStart: { distance: number; start: number; end: number } | null = null;
onMount(() => {
const unregisterGroup = registerGroupMember(group);
const interactionElement = containerEl;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) width = entry.contentRect.width;
});
if (interactionElement) observer.observe(interactionElement);
interactionElement?.addEventListener('wheel', handleWheel, { passive: false });
const themeObserver = new MutationObserver(() => themeVersion++);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme']
});
registerExporter?.({ getExportImage });
return () => {
interactionElement?.removeEventListener('wheel', handleWheel);
observer.disconnect();
themeObserver.disconnect();
unregisterGroup();
registerExporter?.(null);
};
});
function scheduleHover(clientX: number): void {
queuedClientX = clientX;
if (hoverFrame !== 0) return;
hoverFrame = requestAnimationFrame(() => {
hoverFrame = 0;
if (!containerEl || timestampsSec.length === 0) return;
const localX = queuedClientX - containerEl.getBoundingClientRect().left;
const time =
viewStart + ((localX - plotInsetLeft) / Math.max(1, plotWidth)) * (viewEnd - viewStart);
setGroupHover(
group,
localX >= plotInsetLeft && localX <= width - plotInsetRight
? timestampsSec[nearestIndex(timestampsSec, time)]
: null
);
});
}
function handlePointerDown(event: PointerEvent): void {
if (!containerEl) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pointers.size === 2) {
containerEl.setPointerCapture(event.pointerId);
const [first, second] = [...pointers.values()];
pinchStart = {
distance: Math.max(10, Math.abs(first.x - second.x)),
start: viewStart,
end: viewEnd
};
panStart = null;
dragSelect = null;
gesture = 'pinch';
setGroupHover(group, null);
return;
}
if (event.pointerType === 'mouse') {
containerEl.setPointerCapture(event.pointerId);
selectionStartX = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
dragSelect = null;
gesture = 'select';
event.preventDefault();
} else {
touchStart = { x: event.clientX, y: event.clientY, start: viewStart, end: viewEnd };
gesture = 'none';
}
}
function handlePointerMove(event: PointerEvent): void {
if (!containerEl) return;
if (pointers.has(event.pointerId)) {
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
}
if (gesture === 'pinch' && pointers.size === 2 && pinchStart) {
const [first, second] = [...pointers.values()];
const distance = Math.max(10, Math.abs(first.x - second.x));
const scale = pinchStart.distance / distance;
const span = pinchStart.end - pinchStart.start;
const center = (pinchStart.start + pinchStart.end) / 2;
applyRange(center - (span * scale) / 2, center + (span * scale) / 2);
return;
}
if (gesture === 'select' && event.pointerType === 'mouse' && pointers.size === 1) {
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
if (dragSelect || Math.abs(x - selectionStartX) >= 3) {
dragSelect = { x0: selectionStartX, x1: x };
setGroupHover(group, null);
}
return;
}
if (event.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
const deltaX = Math.abs(event.clientX - touchStart.x);
const deltaY = Math.abs(event.clientY - touchStart.y);
if (deltaX < 6 && deltaY < 6) return;
if (deltaY > deltaX) {
gesture = 'scroll';
return;
}
gesture = zoomed ? 'pan' : 'inspect';
containerEl.setPointerCapture(event.pointerId);
if (gesture === 'pan') {
panStart = { x: event.clientX, start: touchStart.start, end: touchStart.end };
}
}
if (gesture === 'scroll') return;
if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) {
const deltaTime =
((panStart.x - event.clientX) / Math.max(1, plotWidth)) *
(panStart.end - panStart.start);
applyRange(panStart.start + deltaTime, panStart.end + deltaTime);
return;
}
if (pointers.size <= 1) scheduleHover(event.clientX);
}
function handlePointerUp(event: PointerEvent): void {
if (gesture === 'select' && event.pointerType === 'mouse' && dragSelect) {
const first = timeForX(dragSelect.x0);
const second = timeForX(dragSelect.x1);
applyRange(Math.min(first, second), Math.max(first, second));
}
dragSelect = null;
pointers.delete(event.pointerId);
if (pointers.size < 2) pinchStart = null;
if (pointers.size === 0) {
panStart = null;
touchStart = null;
gesture = 'none';
}
if (event.pointerType !== 'mouse') setGroupHover(group, null);
}
function handlePointerLeave(): void {
if (pointers.size === 0) setGroupHover(group, null);
}
function handleWheel(event: WheelEvent): void {
if ((!event.ctrlKey && !event.metaKey) || !containerEl) return;
event.preventDefault();
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
zoomAt(timeForX(x), event.deltaY < 0 ? 1 / 1.3 : 1.3);
}
function handleDoubleClick(): void {
setGroupRange(group, null);
}
function handleKeydown(event: KeyboardEvent): void {
if (timestampsSec.length === 0) return;
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const current = sharedHover ?? viewStart;
const index = nearestIndex(timestampsSec, current);
const delta = event.key === 'ArrowLeft' ? -1 : 1;
const next = Math.max(0, Math.min(timestampsSec.length - 1, index + delta));
setGroupHover(group, timestampsSec[next]);
event.preventDefault();
} else if (event.key === '+' || event.key === '=') {
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1 / 1.3);
event.preventDefault();
} else if (event.key === '-' || event.key === '_') {
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1.3);
event.preventDefault();
} else if (event.key === 'Escape') {
setGroupRange(group, null);
setGroupHover(group, null);
}
}
onDestroy(() => {
if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
});
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
const key = `${name}|${color}`;
let promise = iconImageCache.get(key);
if (!promise) {
promise = fetch(`/images/weather-icons/${name}.svg`)
.then((response) => response.text())
.then(
(svg) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const colored = svg.replace(/<svg\b/, `<svg fill="${color}"`);
const url = URL.createObjectURL(new Blob([colored], { type: 'image/svg+xml' }));
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = (error) => {
URL.revokeObjectURL(url);
reject(error);
};
image.src = url;
})
);
iconImageCache.set(key, promise);
}
return promise;
}
export async function getExportImage(opts?: {
title?: string;
}): Promise<HTMLCanvasElement | null> {
if (!containerEl || width <= 0 || displayModels.length === 0) return null;
const dpr = window.devicePixelRatio || 1;
const titleHeight = opts?.title ? EXPORT_TITLE_H : 0;
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * dpr);
canvas.height = Math.round((height + titleHeight) * dpr);
const context = canvas.getContext('2d');
if (!context) return null;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
const styles = getComputedStyle(containerEl);
const color = (name: string, fallback: string): string =>
styles.getPropertyValue(name).trim() || fallback;
const background = color('--card', '#ffffff');
const foreground = color('--foreground', '#1f2937');
const muted = color('--muted-foreground', '#6b7280');
const grid = color('--border', 'rgba(0, 0, 0, 0.12)');
const sunlight = color('--chart-3', '#f6c453');
context.fillStyle = background;
context.fillRect(0, 0, width, height + titleHeight);
if (opts?.title) {
context.fillStyle = foreground;
context.font = '600 14px system-ui, -apple-system, sans-serif';
context.textBaseline = 'middle';
context.fillText(opts.title, 4, EXPORT_TITLE_H / 2 + 2);
}
const yOffset = titleHeight;
context.save();
context.beginPath();
context.rect(plotInsetLeft, yOffset, plotWidth, height);
context.clip();
const sunlightY = yOffset + HEADER_H - SUNLIGHT_STRIP_H;
context.fillStyle = muted;
context.globalAlpha = 0.12;
context.fillRect(plotInsetLeft, sunlightY, plotWidth, SUNLIGHT_STRIP_H);
context.fillStyle = sunlight;
context.globalAlpha = 0.9;
for (const segment of sunlightSegments) {
context.fillRect(segment.x1, sunlightY, segment.x2 - segment.x1, SUNLIGHT_STRIP_H);
}
context.globalAlpha = 1;
context.strokeStyle = grid;
context.lineWidth = 1;
for (const separator of daySeparators) {
context.beginPath();
context.moveTo(separator.x, yOffset);
context.lineTo(separator.x, yOffset + height);
context.stroke();
}
context.restore();
context.font = '600 10px system-ui, -apple-system, sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
for (const point of visiblePoints) {
if (point.date) {
context.fillStyle = foreground;
context.textAlign = 'left';
context.fillText(point.date, Math.max(plotInsetLeft + 3, point.x + 3), yOffset + 9);
}
context.fillStyle = muted;
context.textAlign = 'center';
context.fillText(point.hour, point.x, yOffset + 27);
}
const iconNames = new Set<string>();
for (const model of displayModels) {
for (const point of visiblePoints) {
const code = model.variables.weather_code?.[point.index];
if (!hasWeatherIcon(code)) continue;
iconNames.add(getWeatherIconName(code, isDaytime(point.time)));
}
}
const images = new Map<string, HTMLImageElement>();
await Promise.all(
[...iconNames].map(async (name) => {
try {
images.set(name, await loadColoredIcon(name, foreground));
} catch {
/* An individual missing icon should not prevent the report export. */
}
// Fallback: before the first sunrise
if (sunrise.length > 0 && tsS < sunrise[0]) return false;
return true;
})
);
/**
* Filters models that actually have weather_code data available in the response.
*/
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
context.font = '600 11px system-ui, -apple-system, sans-serif';
context.textBaseline = 'middle';
for (let row = 0; row < displayModels.length; row++) {
const model = displayModels[row];
const rowTop = yOffset + HEADER_H + row * ROW_H;
const rowCenter = rowTop + ROW_H / 2;
context.strokeStyle = grid;
context.globalAlpha = 0.55;
context.beginPath();
context.moveTo(0, rowTop);
context.lineTo(width, rowTop);
context.stroke();
context.globalAlpha = 1;
context.fillStyle = modelColor(model.modelId, modelOrder);
context.beginPath();
context.arc(9, rowCenter, 4, 0, Math.PI * 2);
context.fill();
context.fillStyle = foreground;
context.textAlign = 'left';
context.fillText(modelLabel(model.modelId), 18, rowCenter, Math.max(20, plotInsetLeft - 24));
context.save();
context.beginPath();
// The right chart inset is empty display space, so pictograms at the
// final chart coordinate may extend into it without being clipped.
context.rect(plotInsetLeft, rowTop, width - plotInsetLeft, ROW_H);
context.clip();
for (const point of visiblePoints) {
const code = model.variables.weather_code?.[point.index];
if (!hasWeatherIcon(code)) continue;
const cloudCover = model.variables.cloud_cover?.[point.index];
const name = getWeatherIconName(code, isDaytime(point.time));
const image = images.get(name);
if (cloudCover !== undefined && Number.isFinite(cloudCover)) {
const fraction = Math.max(0, Math.min(100, cloudCover)) / 100;
context.fillStyle = muted;
context.globalAlpha = 0.03 + 0.34 * Math.pow(fraction, 0.85);
context.beginPath();
context.roundRect(
point.x - ICON_BOX_WIDTH / 2,
rowCenter - ICON_BOX_HEIGHT / 2,
ICON_BOX_WIDTH,
ICON_BOX_HEIGHT,
4
);
context.fill();
context.globalAlpha = 1;
}
if (image) {
context.drawImage(
image,
point.x - ICON_SIZE / 2,
rowCenter - ICON_SIZE / 2,
ICON_SIZE,
ICON_SIZE
);
}
}
context.restore();
}
return canvas;
}
// The visible panel is drawn through the exact same export renderer. This
// keeps icon sampling, cloud-cover boxes, labels, and plot geometry identical in
// the page and downloaded PNG.
$effect(() => {
const target = canvasEl;
const currentWidth = width;
const currentHeight = height;
void themeVersion;
void visiblePoints;
void daySeparators;
void sunlightSegments;
void displayModels;
if (!target || currentWidth <= 0 || currentHeight <= 0) return;
const version = ++screenRenderVersion;
void getExportImage().then((rendered) => {
if (!rendered || version !== screenRenderVersion || target !== canvasEl) return;
target.width = rendered.width;
target.height = rendered.height;
const context = target.getContext('2d');
if (!context) return;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, target.width, target.height);
context.drawImage(rendered, 0, 0);
});
return () => {
if (version === screenRenderVersion) screenRenderVersion++;
};
});
</script>
{#snippet weatherIcon(name: string, size: number = 24)}
<svg class="inline-block fill-foreground" width={size} height={size}>
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg>
{/snippet}
{#if displayModels.length > 0}
<div class="mt-8">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-bold">{m.compare_timeline_title()}</h3>
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
<span class="select-none text-muted-foreground">3h</span>
<button
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
title={m.interval_toggle()}
<section
class="mt-7 -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"
aria-labelledby="model-pictogram-title"
>
<span
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
{hourlyInterval === 1 ? 'left-5.5' : 'left-0.75'}"
></span>
</button>
<span class="select-none text-muted-foreground">1h</span>
</div>
<div class="px-3 pt-3 pb-2 lg:px-4">
<h2 id="model-pictogram-title" class="text-sm font-bold tracking-tight">
{m.compare_timeline_title()}
</h2>
<p class="text-xs text-muted-foreground">{m.compare_timeline_hint()}</p>
</div>
<div class="lg:px-4">
<div
class="overflow-x-auto rounded-lg border border-border bg-card shadow-sm"
style="scrollbar-width: thin"
bind:this={containerEl}
class="relative w-full select-none overflow-hidden"
style:touch-action="pan-y"
role="region"
tabindex="0"
aria-label={m.compare_timeline_scroll_aria()}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
onpointerleave={handlePointerLeave}
ondblclick={handleDoubleClick}
onkeydown={handleKeydown}
>
<table class="w-full border-collapse">
<thead>
<tr class="bg-muted/30">
<th
class="sticky left-0 z-20 w-32 border-b border-r border-border bg-muted/95 p-2 text-left text-xs font-bold"
<canvas
bind:this={canvasEl}
class="block w-full"
style:height="{height}px"
role="img"
aria-label={m.compare_timeline_caption()}
></canvas>
{#if dragSelect}
<div
class="pointer-events-none absolute top-0 z-10 border-x-2 border-primary/70 bg-primary/15"
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
style:height="{height}px"
></div>
{/if}
{#if hoverX !== null}
<div
class="pointer-events-none absolute top-0 left-0 border-l border-dashed border-muted-foreground/70 will-change-transform"
style:height="{height}px"
style:transform="translateX({hoverX}px)"
></div>
{/if}
{#if tooltipVisible && hoverX !== null && sharedHover !== null}
<div
class="pointer-events-none absolute z-20 w-max max-w-[calc(100%-1rem)] rounded-md border border-border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
style:top="{HEADER_H + 4}px"
style:left={tooltipFlip ? 'auto' : `${hoverX + 12}px`}
style:right={tooltipFlip ? '0.5rem' : 'auto'}
>
{m.compare_model_label()}
</th>
{#each filteredIndices as idx, i (idx)}
{@const ts = timestamps[idx]}
{@const isNewDay = checkNewDay(i, ts)}
<th
class="min-w-11 border-b border-r border-border/50 p-2 text-center text-[10px] {isNewDay
? 'border-l-2 border-l-primary/30'
: ''}"
>
<div class="font-bold">{formatZoned(new Date(ts), timezone, 'HH')}</div>
<div class="text-muted-foreground">
{isNewDay ? formatZoned(new Date(ts), timezone, 'EEE d') : ''}
<div class="mb-1 font-semibold whitespace-nowrap">
{formatZoned(new Date(sharedHover * 1000), timezone, 'EEE d MMM HH:mm')}
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each displayModels as model (model)}
<tr class="group hover:bg-muted/10">
<td
class="sticky left-0 z-10 border-b border-r border-border bg-card p-2 text-[11px] font-semibold group-hover:bg-muted/20"
>
{model.replace(/_/g, ' ')}
</td>
{#each filteredIndices as idx, i (idx)}
{@const ts = timestamps[idx]}
{@const codes = hourlyFlat[`weather_code_${model}`]}
{@const code = codes ? codes[idx] : 0}
{@const day = allDaytimeFlags[idx]}
{@const isNewDay = checkNewDay(i, ts)}
<td
class="border-b border-r border-border/30 p-1.5 text-center {isNewDay
? 'border-l-2 border-l-primary/20'
: ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}"
>
{@render weatherIcon(getWeatherIconName(code, day))}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{#each tooltipRows as row (row.modelId)}
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
<span
class="size-2 shrink-0 rounded-full"
style:background-color={row.color}
></span>
<span class="min-w-0 break-words">{row.label}:</span>
<span class="pl-2 text-right font-semibold whitespace-nowrap">
{row.condition}{#if row.cloudCover !== null}
· {m.var_cloud()} {row.cloudCover}%
{/if}
</span>
</div>
{/each}
</div>
{/if}
<style>
th,
td {
white-space: nowrap;
}
</style>
</div>
</div>
</section>
{/if}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
cardinalDirection,
isWindDirection,
modelColor,
modelMean,
sanitizeList,
validDirectionValues
} from './comparison';
import type { ModelSeriesData } from '$lib/services/weather';
const models: ModelSeriesData[] = [
{
modelId: 'model-a',
resolvedModelId: 'model-a',
variables: {
precipitation: [1, Number.NaN, 3],
precipitation_probability: [80, 90, 100]
}
},
{
modelId: 'model-b',
resolvedModelId: 'model-b',
variables: { precipitation: [3, Number.NaN, 5] }
}
];
describe('multimodel comparison helpers', () => {
it('averages only the exact scalar variable and preserves missing values', () => {
expect(modelMean(models, 'precipitation', 3)).toEqual([2, null, 4]);
});
it('validates and deduplicates shared URL selections', () => {
const allowed = new Set(['a', 'b']);
expect(sanitizeList(['a', 'unknown', 'a', 'b'], allowed)).toEqual(['a', 'b']);
expect(sanitizeList(['unknown'], allowed)).toBeNull();
});
it('treats every wind direction height as a direction series', () => {
expect(isWindDirection('wind_direction_10m')).toBe(true);
expect(isWindDirection('wind_direction_180m')).toBe(true);
expect(isWindDirection('wind_speed_10m')).toBe(false);
});
it('drops direction values outside the fixed 0360 degree domain', () => {
expect(validDirectionValues([-1, 0, 90, 360, 361, Number.NaN])).toEqual([
null,
0,
90,
360,
null,
null
]);
expect(cardinalDirection(0)).toBe('N');
expect(cardinalDirection(270)).toBe('W');
});
it('assigns deterministic model colors', () => {
expect(modelColor('ecmwf_ifs')).toBe(modelColor('ecmwf_ifs'));
expect(modelColor('ecmwf_ifs')).not.toBe(modelColor('gfs_global'));
});
});
@@ -0,0 +1,130 @@
import { findModel, models as modelOptions } from '../../options';
import type { ModelSeriesData } from '$lib/services/weather';
const MODEL_COLORS = [
'#0072b2',
'#d55e00',
'#009e73',
'#cc79a7',
'#e69f00',
'#56b4e9',
'#6f4ead',
'#8c564b',
'#e11d48',
'#65a30d',
'#0891b2',
'#c026d3',
'#2563eb',
'#ea580c',
'#059669',
'#9333ea'
] as const;
/**
* High-contrast categorical colour. Within a comparison, selection order owns
* the palette slot so adjacent series never receive accidentally similar hues.
*/
export function modelColor(modelId: string, modelOrder?: readonly string[]): string {
const selectedIndex = modelOrder?.indexOf(modelId) ?? -1;
if (selectedIndex >= 0) return MODEL_COLORS[selectedIndex % MODEL_COLORS.length]!;
const canonicalIndex = modelOptions.findIndex((model) => model.value === modelId);
if (canonicalIndex >= 0) return MODEL_COLORS[canonicalIndex % MODEL_COLORS.length]!;
let hash = 2166136261;
for (let i = 0; i < modelId.length; i++) {
hash ^= modelId.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return MODEL_COLORS[Math.abs(hash) % MODEL_COLORS.length]!;
}
export function modelLabel(modelId: string): string {
return findModel(modelId)?.label ?? modelId.replace(/_/g, ' ');
}
export function sanitizeList(
values: string[] | null,
allowed: ReadonlySet<string>
): string[] | null {
if (!values) return null;
const clean = [...new Set(values.filter((value) => allowed.has(value)))];
return clean.length > 0 ? clean : null;
}
export function isWindDirection(variable: string): boolean {
return variable.startsWith('wind_direction_');
}
export function validDirectionValues(values: number[] | undefined): (number | null)[] {
return (values ?? []).map((value) =>
Number.isFinite(value) && value >= 0 && value <= 360 ? value : null
);
}
export interface PrecipitationAgreementPoint {
wetCount: number;
availableCount: number;
median: number;
min: number;
max: number;
}
/** Per-timestamp precipitation occurrence agreement and amount spread. */
export function precipitationAgreement(
models: ModelSeriesData[],
variable: string,
timeLength: number,
wetThreshold: number
): (PrecipitationAgreementPoint | null)[] {
const points: (PrecipitationAgreementPoint | null)[] = new Array(timeLength).fill(null);
for (let index = 0; index < timeLength; index++) {
const values: number[] = [];
for (const model of models) {
const value = model.variables[variable]?.[index];
if (value === undefined || !Number.isFinite(value) || value < 0) continue;
values.push(value);
}
if (values.length === 0) continue;
values.sort((left, right) => left - right);
const middle = Math.floor(values.length / 2);
const median =
values.length % 2 === 0 ? (values[middle - 1] + values[middle]) / 2 : values[middle];
points[index] = {
wetCount: values.filter((value) => value > wetThreshold).length,
availableCount: values.length,
median,
min: values[0],
max: values[values.length - 1]
};
}
return points;
}
export function cardinalDirection(value: number): string {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
return directions[Math.round((((value % 360) + 360) % 360) / 45) % 8];
}
/** Arithmetic model mean for scalar variables; missing consensus stays missing. */
export function modelMean(
models: ModelSeriesData[],
variable: string,
timeLength: number
): (number | null)[] {
const totals = new Array<number>(timeLength).fill(0);
const counts = new Array<number>(timeLength).fill(0);
for (const model of models) {
const values = model.variables[variable] ?? [];
for (let i = 0; i < Math.min(values.length, timeLength); i++) {
const value = values[i];
if (!Number.isFinite(value)) continue;
totals[i] += value;
counts[i]++;
}
}
return totals.map((total, i) =>
counts[i] > 0 ? Math.round((total / counts[i]) * 10) / 10 : null
);
}
+64 -71
View File
@@ -27,9 +27,57 @@ export interface WeatherModelGroup {
*/
export const modelGroups: WeatherModelGroup[] = [
{
value: 'auto',
label: 'Automatic',
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }]
value: 'automatic_seamless',
label: 'Automatic & seamless',
models: [
{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' },
{
value: 'icon_seamless',
label: 'DWD ICON Seamless',
resolution: '2-13 km',
update: 'every 3 h'
},
{ value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' },
{
value: 'meteofrance_seamless',
label: 'MF Seamless',
resolution: '1-25 km',
update: 'every 3 h'
},
{
value: 'ukmo_seamless',
label: 'UKMO Seamless',
resolution: '2-10 km',
update: 'every 3 h'
},
{
value: 'knmi_seamless',
label: 'KNMI Seamless',
resolution: '2-3 km',
update: 'every hour'
},
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
{
value: 'metno_seamless',
label: 'MET Norway Seamless',
resolution: '1 km',
update: 'every 3 h'
},
{
value: 'meteoswiss_icon_seamless',
label: 'MeteoSwiss ICON Seamless',
resolution: '1-2 km',
update: 'every 3 h'
},
{
value: 'chmi_aladin_seamless',
label: 'CHMI Aladin Seamless',
resolution: '1-2.3 km',
update: 'every 6 h'
},
{ value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' },
{ value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' }
]
},
{
value: 'ecmwf',
@@ -49,12 +97,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'dwd',
label: 'DWD Germany',
models: [
{
value: 'icon_seamless',
label: 'DWD ICON Seamless',
resolution: '2-13 km',
update: 'every 3 h'
},
{ value: 'icon_global', label: 'DWD ICON', resolution: '13 km', update: 'every 6 h' },
{ value: 'icon_eu', label: 'DWD ICON EU', resolution: '7 km', update: 'every 3 h' },
{ value: 'icon_d2', label: 'DWD ICON D2', resolution: '2 km', update: 'every 3 h' }
@@ -64,7 +106,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'ncep',
label: 'NOAA U.S.',
models: [
{ value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' },
{ value: 'gfs_global', label: 'GFS Global', resolution: '13 km', update: 'every 6 h' },
{ value: 'gfs_hrrr', label: 'GFS HRRR Conus', resolution: '3 km', update: 'every hour' },
{
@@ -98,12 +139,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'meteofrance',
label: 'Météo-France',
models: [
{
value: 'meteofrance_seamless',
label: 'MF Seamless',
resolution: '1-25 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arpege_world',
label: 'MF ARPEGE World',
@@ -134,12 +169,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'ukmo',
label: 'UK Met Office',
models: [
{
value: 'ukmo_seamless',
label: 'UKMO Seamless',
resolution: '2-10 km',
update: 'every 3 h'
},
{
value: 'ukmo_global_deterministic_10km',
label: 'UK Met Office 10km',
@@ -158,12 +187,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'knmi',
label: 'KNMI Netherlands',
models: [
{
value: 'knmi_seamless',
label: 'KNMI Seamless',
resolution: '2-3 km',
update: 'every hour'
},
{
value: 'knmi_harmonie_arome_europe',
label: 'KNMI Harmonie Arome Europe',
@@ -182,7 +205,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'dmi',
label: 'DMI Denmark',
models: [
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
{
value: 'dmi_harmonie_arome_europe',
label: 'DMI Harmonie Arome Europe',
@@ -195,12 +217,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'metno',
label: 'MET Norway',
models: [
{
value: 'metno_seamless',
label: 'MET Norway Seamless',
resolution: '1 km',
update: 'every 3 h'
},
{ value: 'metno_nordic', label: 'MET Norway Nordic', resolution: '1 km', update: 'every 3 h' }
]
},
@@ -208,12 +224,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'meteoswiss',
label: 'MeteoSwiss',
models: [
{
value: 'meteoswiss_icon_seamless',
label: 'MeteoSwiss ICON Seamless',
resolution: '1-2 km',
update: 'every 3 h'
},
{
value: 'meteoswiss_icon_ch1',
label: 'MeteoSwiss ICON CH1',
@@ -228,25 +238,10 @@ export const modelGroups: WeatherModelGroup[] = [
}
]
},
{
value: 'kma',
label: 'KMA Korea',
models: [
{
value: 'kma_seamless',
label: 'KMA Seamless',
resolution: '1.5-13 km',
update: 'every 3 h'
},
{ value: 'kma_ldps', label: 'KMA LDPS', resolution: '1.5 km', update: 'every 3 h' },
{ value: 'kma_gdps', label: 'KMA GDPS 12km', resolution: '13 km', update: 'every 3 h' }
]
},
{
value: 'jma',
label: 'JMA Japan',
models: [
{ value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' },
{ value: 'jma_msm', label: 'JMA MSM', resolution: '5 km', update: 'every 3 h' },
{ value: 'jma_gsm', label: 'JMA GSM', resolution: '55 km', update: 'every 6 h' }
]
@@ -264,14 +259,20 @@ export const modelGroups: WeatherModelGroup[] = [
]
},
{
value: 'bom',
label: 'BOM Australia',
value: 'chmi',
label: 'CHMI Czech Republic',
models: [
{
value: 'bom_access_global',
label: 'BOM ACCESS Global',
resolution: '15 km',
update: 'every 12 h'
value: 'chmi_aladin_central_europe_2km',
label: 'CHMI Aladin Central Europe 2km',
resolution: '2.3 km',
update: 'every 6 h'
},
{
value: 'chmi_aladin_cz_1km',
label: 'CHMI Aladin CZ 1km',
resolution: '1 km',
update: 'every 6 h'
}
]
},
@@ -279,7 +280,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'cmc_gem',
label: 'GEM Canada',
models: [
{ value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' },
{ value: 'gem_global', label: 'GEM Global', resolution: '15 km', update: 'every 12 h' },
{ value: 'gem_regional', label: 'GEM Regional', resolution: '10 km', update: 'every 6 h' },
{
@@ -433,11 +433,6 @@ export const ensembleModelGroups: WeatherModelGroup[] = [
}
]
},
{
value: 'bom',
label: 'BOM Australia',
models: [{ value: 'bom_access_global_ensemble', label: 'BOM ACCESS Global' }]
},
{
value: 'ukmo',
label: 'UK Met Office',
@@ -503,9 +498,7 @@ const IN_DOMAIN_CITY: [string, { slug: string; label: string }][] = [
['nbm', { slug: 'new-york', label: 'New York' }],
['gem', { slug: 'toronto', label: 'Toronto' }],
['jma', { slug: 'tokyo', label: 'Tokyo' }],
['kma', { slug: 'seoul', label: 'Seoul' }],
['cma', { slug: 'beijing', label: 'Beijing' }],
['bom', { slug: 'sydney', label: 'Sydney' }]
['cma', { slug: 'beijing', label: 'Beijing' }]
];
/** Where this model definitely has data, or null for a global model. */
@@ -83,6 +83,14 @@ const weatherCodes: Record<number, string> = {
// These conditions ship only as a single neutral glyph (no day/night variant).
const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']);
export function hasWeatherIcon(code: unknown): code is number {
return (
typeof code === 'number' &&
Number.isFinite(code) &&
Object.prototype.hasOwnProperty.call(weatherCodes, code)
);
}
export function getWeatherIconName(code: number, daytime: boolean): string {
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`;