multimodel improvements

This commit is contained in:
terraputix
2026-08-02 22:24:51 +02:00
parent bde815c35a
commit ea9eb17d75
19 changed files with 2493 additions and 826 deletions
+37
View File
@@ -281,8 +281,45 @@
"model_selector_aria": "Auswahl: {label}", "model_selector_aria": "Auswahl: {label}",
"compare_models_heading": "Modelle", "compare_models_heading": "Modelle",
"compare_variables_heading": "Stündliche Wettervariablen", "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_no_models_selected": "Keine Modelle ausgewählt.",
"compare_remove_model": "{model} entfernen",
"compare_timeline_title": "Zeitleiste des Modellvergleichs", "compare_timeline_title": "Zeitleiste des Modellvergleichs",
"compare_model_label": "Modell", "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.", "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_before": "Eine saisonale Vorhersage zeigt, wie stark ein ganzer Monat voraussichtlich",
"seasonal_explainer_strong": "von seiner Klimanormalen abweicht", "seasonal_explainer_strong": "von seiner Klimanormalen abweicht",
+37
View File
@@ -281,8 +281,45 @@
"model_selector_aria": "{label} selection", "model_selector_aria": "{label} selection",
"compare_models_heading": "Models", "compare_models_heading": "Models",
"compare_variables_heading": "Hourly Weather Variables", "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_no_models_selected": "No models selected.",
"compare_remove_model": "Remove {model}",
"compare_timeline_title": "Model Comparison Timeline", "compare_timeline_title": "Model Comparison Timeline",
"compare_model_label": "Model", "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.", "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_before": "A seasonal forecast shows how a whole month is likely to",
"seasonal_explainer_strong": "depart from its climate normal", "seasonal_explainer_strong": "depart from its climate normal",
+37
View File
@@ -281,8 +281,45 @@
"model_selector_aria": "Selección de {label}", "model_selector_aria": "Selección de {label}",
"compare_models_heading": "Modelos", "compare_models_heading": "Modelos",
"compare_variables_heading": "Variables meteorológicas horarias", "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_no_models_selected": "No hay modelos seleccionados.",
"compare_remove_model": "Quitar {model}",
"compare_timeline_title": "Cronología de comparación de modelos", "compare_timeline_title": "Cronología de comparación de modelos",
"compare_model_label": "Modelo", "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.", "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_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", "seasonal_explainer_strong": "se desvíe de su normal climática",
+37
View File
@@ -281,8 +281,45 @@
"model_selector_aria": "Sélection : {label}", "model_selector_aria": "Sélection : {label}",
"compare_models_heading": "Modèles", "compare_models_heading": "Modèles",
"compare_variables_heading": "Variables météo horaires", "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_no_models_selected": "Aucun modèle sélectionné.",
"compare_remove_model": "Supprimer {model}",
"compare_timeline_title": "Chronologie de comparaison des modèles", "compare_timeline_title": "Chronologie de comparaison des modèles",
"compare_model_label": "Modèle", "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.", "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_before": "Une prévision saisonnière montre dans quelle mesure un mois entier devrait",
"seasonal_explainer_strong": "s'écarter de sa normale climatique", "seasonal_explainer_strong": "s'écarter de sa normale climatique",
+37
View File
@@ -281,8 +281,45 @@
"model_selector_aria": "Selezione di {label}", "model_selector_aria": "Selezione di {label}",
"compare_models_heading": "Modelli", "compare_models_heading": "Modelli",
"compare_variables_heading": "Variabili meteo orarie", "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_no_models_selected": "Nessun modello selezionato.",
"compare_remove_model": "Rimuovi {model}",
"compare_timeline_title": "Cronologia del confronto tra modelli", "compare_timeline_title": "Cronologia del confronto tra modelli",
"compare_model_label": "Modello", "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.", "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_before": "Una previsione stagionale mostra quanto un intero mese potrebbe",
"seasonal_explainer_strong": "discostarsi dalla sua normale climatica", "seasonal_explainer_strong": "discostarsi dalla sua normale climatica",
+249 -40
View File
@@ -24,13 +24,15 @@
/** Series display name (used in legend and tooltip) */ /** Series display name (used in legend and tooltip) */
name: string; name: string;
/** Render style */ /** Render style */
type: 'line' | 'bar'; type: 'line' | 'bar' | 'point';
/** Any CSS color string */ /** Any CSS color string */
color: string; color: string;
/** One value per timestamp; null values break lines */ /** One value per timestamp; null values break lines */
data: (number | null)[]; data: (number | null)[];
/** Line width in px (default 2); 0 draws only the area fill */ /** Line width in px (default 2); 0 draws only the area fill */
width?: number; 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 */ /** Draw a low-alpha area fill below (or above, on inverted axes) the line */
fill?: boolean; fill?: boolean;
/** Area fill coloured by the value scale (segmentColor), fading to /** Area fill coloured by the value scale (segmentColor), fading to
@@ -74,6 +76,20 @@
cloudLayer?: 'high' | 'mid' | 'low'; 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 { interface GroupState {
range: { start: number; end: number } | null; range: { start: number; end: number } | null;
hover: 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 * 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 * 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 { export function setGroupHover(name: string, time: number | null): void {
const state = groups[name]; 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. */ /** Current shared zoom range of a group (null = full range), reactive. */
@@ -151,6 +187,8 @@
windArrows?: { t: number; deg: number }[]; windArrows?: { t: number; deg: number }[];
/** Highlighted time range (epoch seconds), e.g. the selected day */ /** Highlighted time range (epoch seconds), e.g. the selected day */
highlight?: { start: number; end: number }; 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 label for the left y axis (also used in tooltip values) */
unit?: string; unit?: string;
/** Unit label for the right y axis; when set, right-axis labels are drawn */ /** Unit label for the right y axis; when set, right-axis labels are drawn */
@@ -167,6 +205,10 @@
yPadBottom?: number; yPadBottom?: number;
/** Fixed left-axis maximum */ /** Fixed left-axis maximum */
yMax?: number; 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) */ /** Force the derived left axis to include zero (default true) */
zeroBaseLeft?: boolean; zeroBaseLeft?: boolean;
/** Fixed right-axis minimum (default 0) */ /** 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 /** Reserve this many top icon-rows even if this chart has fewer, so a row
* of stacked charts share the same plot rectangle */ * of stacked charts share the same plot rectangle */
reserveTopRows?: number; 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 */ /** Chart title drawn top-left on the canvas */
title?: string; title?: string;
/** Smaller subtitle drawn under the title */ /** Smaller subtitle drawn under the title */
@@ -191,6 +237,8 @@
showNow?: boolean; showNow?: boolean;
/** Draw the Open-Meteo.com credit bottom-right */ /** Draw the Open-Meteo.com credit bottom-right */
showCredit?: boolean; showCredit?: boolean;
/** Accessible description for the interactive canvas. */
ariaLabel?: string;
/** Optional CSS class for the outer container */ /** Optional CSS class for the outer container */
class?: string; class?: string;
} }
@@ -203,12 +251,15 @@
pictograms = [], pictograms = [],
windArrows = [], windArrows = [],
highlight, highlight,
agreementStrip,
unit = '', unit = '',
unitRight, unitRight,
height = 300, height = 300,
group, group,
yMin, yMin,
yMax, yMax,
yTicks,
yTickFormat,
yPadTop, yPadTop,
yPadBottom, yPadBottom,
zeroBaseLeft = true, zeroBaseLeft = true,
@@ -217,11 +268,14 @@
invertRight = false, invertRight = false,
reserveRightAxis = false, reserveRightAxis = false,
reserveTopRows = 0, reserveTopRows = 0,
plotInsetLeft,
plotInsetRight,
title, title,
subtitle, subtitle,
showLegend = false, showLegend = false,
showNow = true, showNow = true,
showCredit = false, showCredit = false,
ariaLabel,
class: className = '' class: className = ''
}: Props = $props(); }: Props = $props();
@@ -232,6 +286,8 @@
// ─── Constants ────────────────────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────────────────────
const PAD_BOTTOM = 34; 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 MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600; const HOUR = 3600;
// Top icon rows (weather pictograms / wind arrows) // Top icon rows (weather pictograms / wind arrows)
@@ -274,6 +330,20 @@
return color; 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 * 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 * the values before interpolating gives the band a slow, rolling silhouette
@@ -368,6 +438,10 @@
const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null; const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null;
let localRange = $state<{ start: number; end: number } | null>(null); let localRange = $state<{ start: number; end: number } | null>(null);
let localHover = $state<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(() => { onDestroy(() => {
if (groupName) releaseGroup(groupName); if (groupName) releaseGroup(groupName);
@@ -407,12 +481,14 @@
return () => mq.removeEventListener('change', apply); return () => mq.removeEventListener('change', apply);
}); });
let isNarrow = $derived(belowDesktop || (width > 0 && width < 520)); 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) // 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 // 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 // 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). // 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 // 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 — // 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 // 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 // tighter top/bottom gutters on mobile so charts don't waste vertical space
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H); const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH); 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 plotW = $derived(Math.max(1, width - padLeft - padRight));
let plotH = $derived(Math.max(1, height - padTop - padBottom)); let plotH = $derived(Math.max(1, height - padTop - padBottom));
@@ -536,8 +615,11 @@
} }
function setHover(time: number | null): void { function setHover(time: number | null): void {
if (groupState) groupState.hover = time; if (groupState) {
else localHover = time; if (groupState.hover !== time) groupState.hover = time;
} else if (localHover !== time) {
localHover = time;
}
} }
function applyRange(start: number, end: number): void { function applyRange(start: number, end: number): void {
@@ -569,7 +651,7 @@
// The canvas alone omits the DOM overlays (weather/wind icons), the panel // 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 // 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. // 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> { function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
const key = `${name}|${color}`; const key = `${name}|${color}`;
let p = iconImageCache.get(key); let p = iconImageCache.get(key);
@@ -762,12 +844,23 @@
: `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`; : `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`;
rows.push({ name: s.shortName ?? s.name, color: s.color, value }); 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; return rows;
}); });
let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0); let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0);
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0); let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
let tooltipFlip = $derived(tooltipX > width * 0.55); 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) ───────────────────────────────── // ─── Pictograms (DOM overlay across the top) ─────────────────────────────────
@@ -979,11 +1072,13 @@
ctx.font = font; ctx.font = font;
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
for ( const leftTicks =
let v = firstTick(leftScale); yTicks ??
v <= leftScale.max + leftScale.step / 2; Array.from(
v += leftScale.step { 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'); const y = yPix(v, 'left');
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
@@ -992,7 +1087,7 @@
ctx.lineTo(plotRight, y); ctx.lineTo(plotRight, y);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = textColor; 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 // Right axis labels in the gutter (desktop only). On mobile there is no
@@ -1033,7 +1128,7 @@
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
} }
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(tick.label, x, plotBottom + 7); ctx.fillText(tick.label, x, plotBottom + agreementStripOffset + 7);
} }
// Series (clipped to the plot area) // Series (clipped to the plot area)
@@ -1184,6 +1279,24 @@
run = []; run = [];
continue; 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;
}
run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0, i]); run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0, i]);
} }
if (run.length > 0) runs.push(run); if (run.length > 0) runs.push(run);
@@ -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(); 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 // Axis unit labels
ctx.font = font; ctx.font = font;
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = 'alphabetic';
@@ -1515,13 +1646,47 @@
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none'; let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
let touchStart: { x: number; y: number; start: number; end: number } | null = null; let touchStart: { x: number; y: number; start: number; end: number } | null = null;
let selStartX = 0; // drag-to-zoom anchor (canvas-local px) 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 localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x)); const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x));
const updateHover = (e: PointerEvent): void => { const scheduleHoverFrame = (): void => {
const t = pixToTime(localX(e)); if (hoverFrame !== 0) return;
setHover(t >= viewStart && t <= viewEnd ? t : null); 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 => { const onPointerDown = (e: PointerEvent): void => {
@@ -1533,7 +1698,7 @@
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd }; pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
panStart = null; panStart = null;
gesture = 'pinch'; gesture = 'pinch';
setHover(null); scheduleHoverClear();
return; return;
} }
@@ -1573,7 +1738,7 @@
const x = clampPlotX(localX(e)); const x = clampPlotX(localX(e));
if (dragSelect || Math.abs(x - selStartX) >= 3) { if (dragSelect || Math.abs(x - selStartX) >= 3) {
dragSelect = { x0: selStartX, x1: x }; dragSelect = { x0: selStartX, x1: x };
setHover(null); scheduleHoverClear();
} }
return; return;
} }
@@ -1603,7 +1768,7 @@
return; return;
} }
if (pointers.size <= 1) updateHover(e); if (pointers.size <= 1) scheduleHoverPosition(e.clientX);
}; };
const onPointerUp = (e: PointerEvent): void => { const onPointerUp = (e: PointerEvent): void => {
@@ -1622,11 +1787,11 @@
touchStart = null; touchStart = null;
gesture = 'none'; gesture = 'none';
} }
if (e.pointerType !== 'mouse') setHover(null); if (e.pointerType !== 'mouse') scheduleHoverClear();
}; };
const onPointerLeave = (): void => { const onPointerLeave = (): void => {
if (pointers.size === 0) setHover(null); if (pointers.size === 0) scheduleHoverClear();
}; };
const onWheel = (e: WheelEvent): void => { const onWheel = (e: WheelEvent): void => {
@@ -1660,6 +1825,7 @@
el.removeEventListener('pointerleave', onPointerLeave); el.removeEventListener('pointerleave', onPointerLeave);
el.removeEventListener('wheel', onWheel); el.removeEventListener('wheel', onWheel);
el.removeEventListener('dblclick', onDblClick); el.removeEventListener('dblclick', onDblClick);
if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
}; };
}); });
@@ -1667,6 +1833,29 @@
if (legendHidden.has(name)) legendHidden.delete(name); if (legendHidden.has(name)) legendHidden.delete(name);
else legendHidden.add(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> </script>
<div bind:this={containerEl} class="relative w-full select-none {className}"> <div bind:this={containerEl} class="relative w-full select-none {className}">
@@ -1676,7 +1865,16 @@
class="block w-full" class="block w-full"
style:height="{height}px" style:height="{height}px"
style:touch-action="pan-y" style:touch-action="pan-y"
tabindex="0"
aria-label={ariaLabel}
onkeydown={handleKeydown}
></canvas> ></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 --> <!-- Drag-to-zoom selection rectangle -->
{#if dragSelect} {#if dragSelect}
@@ -1685,7 +1883,7 @@
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px" style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
style:top="{padTop}px" style:top="{padTop}px"
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px" style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
style:height="{plotH}px" style:height="{plotH + agreementStripOffset}px"
></div> ></div>
{/if} {/if}
@@ -1738,6 +1936,17 @@
</div> </div>
{/if} {/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} {#if tooltipVisible}
<div <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" 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 ─────────────────────────────────── // ─── Utility: Detect column-type variables ───────────────────────────────────
/** Units that should be rendered as bar/column charts instead of lines. */ /** 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. * Returns true if the given unit should be rendered as a bar chart.
*/ */
export function isColumnUnit(unit: string): boolean { export function isColumnUnit(unit: string): boolean {
return COLUMN_UNITS.has(unit); return COLUMN_UNITS.has(unit.trim());
} }
// ─── Data Processing Helpers ───────────────────────────────────────────────── // ─── Data Processing Helpers ─────────────────────────────────────────────────
export interface AverageResult { export interface AverageResult {
average: number[]; average: (number | null)[];
averageCount: number[]; averageCount: number[];
} }
@@ -62,7 +62,7 @@ export function calculateAverage(
variable: string, variable: string,
timeLength: number timeLength: number
): AverageResult { ): 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); const averageCount = new Array<number>(timeLength).fill(0);
for (const [model, values] of Object.entries(hourlyData)) { for (const [model, values] of Object.entries(hourlyData)) {
@@ -71,18 +71,17 @@ export function calculateAverage(
for (const [index, val] of (values as number[]).entries()) { for (const [index, val] of (values as number[]).entries()) {
if (val !== null && val !== undefined && isFinite(val)) { if (val !== null && val !== undefined && isFinite(val)) {
average[index] += val; if (index >= timeLength) continue;
totals[index] += val;
averageCount[index]++; averageCount[index]++;
} }
} }
} }
// Finalize average values // Finalize average values
for (let i = 0; i < timeLength; i++) { const average = totals.map((total, i) =>
if (averageCount[i] > 0) { averageCount[i] > 0 ? Math.round((total / averageCount[i]) * 10) / 10 : null
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10; );
}
}
return { average, averageCount }; return { average, averageCount };
} }
+7 -1
View File
@@ -8,10 +8,16 @@
export { export {
default as CanvasChart, default as CanvasChart,
setGroupHover, setGroupHover,
setGroupRange,
registerGroupMember,
groupRange, groupRange,
groupHover groupHover
} from './CanvasChart.svelte'; } from './CanvasChart.svelte';
export type { ChartSeries } from './CanvasChart.svelte'; export type {
ChartAgreementPoint,
ChartAgreementStrip,
ChartSeries
} from './CanvasChart.svelte';
export { buildDaylightBands } from './bands'; export { buildDaylightBands } from './bands';
export type { DaylightBand } from './bands'; export type { DaylightBand } from './bands';
+15 -9
View File
@@ -18,14 +18,15 @@
{/snippet} {/snippet}
</ChartToolbar> </ChartToolbar>
--> -->
<script module lang="ts"> <script lang="ts">
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
export type { ExportableChart } from './downloadChartsPng'; import {
</script> type ChartDownloadOptions,
type ChartExportItem,
<script lang="ts"> type ExportableChart,
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng'; downloadChartsPng
} from './downloadChartsPng';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -33,9 +34,11 @@
interface Props { interface Props {
/** Chart components available for download (undefined entries are skipped) */ /** 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) */ /** Base file name for downloaded images (without extension) */
fileName?: string; fileName?: string;
/** Optional title and shared legend drawn into the combined PNG. */
exportOptions?: ChartDownloadOptions;
/** Optional CSS class for the outer container */ /** Optional CSS class for the outer container */
class?: string; class?: string;
/** Slot for additional controls (switches, checkboxes, etc.) */ /** Slot for additional controls (switches, checkboxes, etc.) */
@@ -45,6 +48,7 @@
let { let {
charts = [], charts = [],
fileName = 'drizzli-chart', fileName = 'drizzli-chart',
exportOptions,
class: className = '', class: className = '',
controls controls
}: Props = $props(); }: Props = $props();
@@ -55,7 +59,9 @@
// ─── Computed ─────────────────────────────────────────────────────────────── // ─── 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 ─────────────────────────────────────────────────────────────── // ─── Download ───────────────────────────────────────────────────────────────
@@ -63,7 +69,7 @@
if (!hasCharts || downloading) return; if (!hasCharts || downloading) return;
downloading = true; downloading = true;
try { try {
await downloadChartsPng(charts, fileName); await downloadChartsPng(charts, fileName, exportOptions);
} finally { } finally {
setTimeout(() => { setTimeout(() => {
downloading = false; downloading = false;
+102 -6
View File
@@ -17,6 +17,17 @@ export interface ChartExportItem {
title?: string; 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. */ /** Resolves the page background so exports match the current theme. */
function exportBackground(): string { function exportBackground(): string {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim(); const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
@@ -47,18 +58,60 @@ function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartE
*/ */
export async function downloadChartsPng( export async function downloadChartsPng(
charts: Array<ChartExportItem | ExportableChart | null | undefined>, charts: Array<ChartExportItem | ExportableChart | null | undefined>,
fileName: string fileName: string,
options: ChartDownloadOptions = {}
): Promise<void> { ): 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; if (items.length === 0) return;
const canvases = ( // A supplementary panel must never prevent the remaining charts from being
await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title }))) // downloaded. Invalid adapters are filtered above; rejected renders are
).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0); // 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; if (canvases.length === 0) return;
const maxWidth = Math.max(...canvases.map((c) => c.width)); 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'); const canvas = document.createElement('canvas');
canvas.width = maxWidth; canvas.width = maxWidth;
@@ -70,7 +123,50 @@ export async function downloadChartsPng(
ctx.fillStyle = exportBackground(); ctx.fillStyle = exportBackground();
ctx.fillRect(0, 0, maxWidth, totalHeight); 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; 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) { for (const c of canvases) {
ctx.drawImage(c, 0, y); ctx.drawImage(c, 0, y);
y += c.height; y += c.height;
+7 -1
View File
@@ -9,4 +9,10 @@
export { default as ChartContainer } from './ChartContainer.svelte'; export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.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';
+27 -133
View File
@@ -10,6 +10,7 @@
* - Efficient binary protobuf transport instead of JSON * - Efficient binary protobuf transport instead of JSON
* - Consistent timestamp and unit handling * - Consistent timestamp and unit handling
*/ */
import { Model } from '@openmeteo/sdk/model';
import { Unit } from '@openmeteo/sdk/unit'; import { Unit } from '@openmeteo/sdk/unit';
import { fetchWeatherApi } from 'openmeteo'; import { fetchWeatherApi } from 'openmeteo';
@@ -241,7 +242,10 @@ export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams {
} }
export interface ModelSeriesData { 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[]>; variables: Record<string, number[]>;
} }
@@ -535,7 +539,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
* compatible with existing chart utilities. * compatible with existing chart utilities.
*/ */
export async function fetchModelComparison( export async function fetchModelComparison(
params: ModelCompareParams params: ModelCompareParams,
options: { signal?: AbortSignal } = {}
): Promise<ModelCompareResult> { ): Promise<ModelCompareResult> {
const forecastApiParams: Record<string, string | number | undefined> = { const forecastApiParams: Record<string, string | number | undefined> = {
latitude: String(params.latitude), latitude: String(params.latitude),
@@ -549,14 +554,23 @@ export async function fetchModelComparison(
timezone: params.timezone 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.');
// With multiple models, we get one response per model // With multiple models, we get one response per model
const firstResponse = responses[0]; const firstResponse = responses[0];
const utcOffsetSeconds = firstResponse.utcOffsetSeconds(); const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
const timezone = firstResponse.timezone() ?? params.timezone ?? 'UTC'; 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); const timestamps = getTimestamps(hourlyBlock);
// Extract sunrise/sunset from the first response's daily block // Extract sunrise/sunset from the first response's daily block
@@ -586,16 +600,20 @@ export async function fetchModelComparison(
); );
hourlyFlat['time'] = timeInUnixSeconds; hourlyFlat['time'] = timeInUnixSeconds;
for (const response of responses) { for (const [responseIndex, response] of responses.entries()) {
const modelHourly = response.hourly(); const modelHourly = response.hourly();
if (!modelHourly) continue; if (!modelHourly) continue;
// Determine model name from the response // Keep the 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 modelEnum = response.model();
const modelName = modelEnumToString(modelEnum); const resolvedModelId = Model[modelEnum] ?? `model_${modelEnum}`;
const modelId = params.models[responseIndex] ?? resolvedModelId;
const modelData: ModelSeriesData = { const modelData: ModelSeriesData = {
modelName, modelId,
resolvedModelId,
variables: {} variables: {}
}; };
@@ -608,7 +626,7 @@ export async function fetchModelComparison(
modelData.variables[varName] = values; modelData.variables[varName] = values;
// Build flat key like "temperature_2m_icon_seamless" // Build flat key like "temperature_2m_icon_seamless"
const flatKey = `${varName}_${modelName}`; const flatKey = `${varName}_${modelId}`;
hourlyFlat[flatKey] = values; hourlyFlat[flatKey] = values;
// Record unit // Record unit
@@ -786,130 +804,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 ───────────────────────────────────────────── // ─── Historical (Archive) Types ─────────────────────────────────────────────
export interface HistoricalDailyData { export interface HistoricalDailyData {
+1 -5
View File
@@ -9,7 +9,7 @@
* automatically once the maps app ships seamless support; until then they fall * automatically once the maps app ships seamless support; until then they fall
* back to their widest-coverage member (a regional member could be entirely * 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 * 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. * resolve to null.
*/ */
const modelDomainCandidates: Record<string, string[]> = { const modelDomainCandidates: Record<string, string[]> = {
@@ -46,10 +46,6 @@ const modelDomainCandidates: Record<string, string[]> = {
// MeteoSwiss (CH2 covers a wider area than CH1) // MeteoSwiss (CH2 covers a wider area than CH1)
meteoswiss_icon_seamless: ['meteoswiss_icon_ch2'], 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'],
// JMA Japan // JMA Japan
jma_seamless: ['jma_seamless', 'jma_gsm'], jma_seamless: ['jma_seamless', 'jma_gsm'],
File diff suppressed because it is too large Load Diff
@@ -1,154 +1,739 @@
<script lang="ts"> <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 * 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 { interface Props {
timestamps: number[]; timestamps: number[];
hourlyFlat: Record<string, number[]>; models: ModelSeriesData[];
models: string[]; modelOrder: string[];
sunrise: number[]; sunrise: number[];
sunset: number[]; sunset: number[];
timezone: string; timezone: string;
group: string;
plotInsetLeft: number;
plotInsetRight: number;
registerExporter?: (exporter: TimelineExporter | null) => void;
} }
let { timestamps, hourlyFlat, models, sunrise, sunset, timezone }: Props = $props(); interface TimelineExporter {
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
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);
} }
return acc;
}, []) interface TimelinePoint {
index: number;
time: number;
x: number;
hour: string;
date: string;
}
interface DaySeparator {
x: 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 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 { function xForTime(time: number): number {
if (i === 0) return false; return plotInsetLeft + ((time - viewStart) / Math.max(1, viewEnd - viewStart)) * plotWidth;
const prevTs = timestamps[filteredIndices[i - 1]]; }
return (
formatZoned(new Date(ts), timezone, 'd') !== formatZoned(new Date(prevTs), timezone, 'd') 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
); );
} }
/** function nearestIndex(values: number[], target: number): number {
* Determines if it's daytime for each timestamp based on sunrise/sunset data. let low = 0;
*/ let high = values.length - 1;
let allDaytimeFlags = $derived( while (low < high) {
timestamps.map((ts) => { const middle = (low + high) >> 1;
const tsS = ts / 1000; 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++) { for (let i = 0; i < sunrise.length; i++) {
const s = sunrise[i]; if (time >= sunrise[i] && time < sunset[i]) return true;
const e = sunset[i]; const nextSunrise = sunrise[i + 1] ?? Infinity;
// Between sunrise and sunset of the same day if (time >= sunset[i] && time < nextSunrise) return false;
if (tsS >= s && tsS < e) return true; }
// Between sunset of day i and sunrise of day i+1 (night) return sunrise.length === 0 || time >= sunrise[0];
const nextS = sunrise[i + 1] || Infinity; }
if (tsS >= e && tsS < nextS) return false;
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 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)');
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();
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.fillStyle = muted;
context.font = '600 9px system-ui, -apple-system, sans-serif';
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillText(m.var_cloud(), 8, yOffset + 11, Math.max(20, plotInsetLeft - 16));
context.font = '9px system-ui, -apple-system, sans-serif';
context.fillText('0100%', 8, yOffset + 27);
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;
}) })
); );
/** context.font = '600 11px system-ui, -apple-system, sans-serif';
* Filters models that actually have weather_code data available in the response. context.textBaseline = 'middle';
*/ for (let row = 0; row < displayModels.length; row++) {
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`])); 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 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> </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} {#if displayModels.length > 0}
<div class="mt-8"> <section
<div class="mb-4 flex items-center justify-between"> class="mt-7 -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"
<h3 class="text-xl font-bold">{m.compare_timeline_title()}</h3> aria-labelledby="model-pictogram-title"
<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()}
> >
<span <div class="px-3 pt-3 pb-2 lg:px-4">
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200 <h2 id="model-pictogram-title" class="text-sm font-bold tracking-tight">
{hourlyInterval === 1 ? 'left-5.5' : 'left-0.75'}" {m.compare_timeline_title()}
></span> </h2>
</button> <p class="text-xs text-muted-foreground">{m.compare_timeline_hint()}</p>
<span class="select-none text-muted-foreground">1h</span>
</div>
</div> </div>
<div class="lg:px-4">
<div <div
class="overflow-x-auto rounded-lg border border-border bg-card shadow-sm" bind:this={containerEl}
style="scrollbar-width: thin" 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"> <canvas
<thead> bind:this={canvasEl}
<tr class="bg-muted/30"> class="block w-full"
<th style:height="{height}px"
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" 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()} <div class="mb-1 font-semibold whitespace-nowrap">
</th> {formatZoned(new Date(sharedHover * 1000), timezone, 'EEE d MMM HH:mm')}
{#each filteredIndices as idx, i (idx)} </div>
{@const ts = timestamps[idx]} {#each tooltipRows as row (row.modelId)}
{@const isNewDay = checkNewDay(i, ts)} <div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
<th <span
class="min-w-11 border-b border-r border-border/50 p-2 text-center text-[10px] {isNewDay class="size-2 shrink-0 rounded-full"
? 'border-l-2 border-l-primary/30' style:background-color={row.color}
: ''}" ></span>
> <span class="min-w-0 break-words">{row.label}:</span>
<div class="font-bold">{formatZoned(new Date(ts), timezone, 'HH')}</div> <span class="pl-2 text-right font-semibold whitespace-nowrap">
<div class="text-muted-foreground"> {row.condition}{#if row.cloudCover !== null}
{isNewDay ? formatZoned(new Date(ts), timezone, 'EEE d') : ''} · {m.var_cloud()} {row.cloudCover}%
{/if}
</span>
</div> </div>
</th>
{/each} {/each}
</tr> </div>
</thead> {/if}
<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>
</div> </div>
</div> </div>
</section>
{/if} {/if}
<style>
th,
td {
white-space: nowrap;
}
</style>
@@ -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
);
}
+46 -77
View File
@@ -27,9 +27,51 @@ export interface WeatherModelGroup {
*/ */
export const modelGroups: WeatherModelGroup[] = [ export const modelGroups: WeatherModelGroup[] = [
{ {
value: 'auto', value: 'automatic_seamless',
label: 'Automatic', label: 'Automatic & seamless',
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }] 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: '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', value: 'ecmwf',
@@ -49,12 +91,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'dwd', value: 'dwd',
label: 'DWD Germany', label: 'DWD Germany',
models: [ 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_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_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' } { value: 'icon_d2', label: 'DWD ICON D2', resolution: '2 km', update: 'every 3 h' }
@@ -64,7 +100,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'ncep', value: 'ncep',
label: 'NOAA U.S.', label: 'NOAA U.S.',
models: [ 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_global', label: 'GFS Global', resolution: '13 km', update: 'every 6 h' },
{ value: 'gfs_hrrr', label: 'GFS HRRR Conus', resolution: '3 km', update: 'every hour' }, { value: 'gfs_hrrr', label: 'GFS HRRR Conus', resolution: '3 km', update: 'every hour' },
{ {
@@ -98,12 +133,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'meteofrance', value: 'meteofrance',
label: 'Météo-France', label: 'Météo-France',
models: [ models: [
{
value: 'meteofrance_seamless',
label: 'MF Seamless',
resolution: '1-25 km',
update: 'every 3 h'
},
{ {
value: 'meteofrance_arpege_world', value: 'meteofrance_arpege_world',
label: 'MF ARPEGE World', label: 'MF ARPEGE World',
@@ -134,12 +163,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'ukmo', value: 'ukmo',
label: 'UK Met Office', label: 'UK Met Office',
models: [ models: [
{
value: 'ukmo_seamless',
label: 'UKMO Seamless',
resolution: '2-10 km',
update: 'every 3 h'
},
{ {
value: 'ukmo_global_deterministic_10km', value: 'ukmo_global_deterministic_10km',
label: 'UK Met Office 10km', label: 'UK Met Office 10km',
@@ -158,12 +181,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'knmi', value: 'knmi',
label: 'KNMI Netherlands', label: 'KNMI Netherlands',
models: [ models: [
{
value: 'knmi_seamless',
label: 'KNMI Seamless',
resolution: '2-3 km',
update: 'every hour'
},
{ {
value: 'knmi_harmonie_arome_europe', value: 'knmi_harmonie_arome_europe',
label: 'KNMI Harmonie Arome Europe', label: 'KNMI Harmonie Arome Europe',
@@ -182,7 +199,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'dmi', value: 'dmi',
label: 'DMI Denmark', label: 'DMI Denmark',
models: [ models: [
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
{ {
value: 'dmi_harmonie_arome_europe', value: 'dmi_harmonie_arome_europe',
label: 'DMI Harmonie Arome Europe', label: 'DMI Harmonie Arome Europe',
@@ -195,12 +211,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'metno', value: 'metno',
label: 'MET Norway', label: 'MET Norway',
models: [ 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' } { value: 'metno_nordic', label: 'MET Norway Nordic', resolution: '1 km', update: 'every 3 h' }
] ]
}, },
@@ -208,12 +218,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'meteoswiss', value: 'meteoswiss',
label: 'MeteoSwiss', label: 'MeteoSwiss',
models: [ models: [
{
value: 'meteoswiss_icon_seamless',
label: 'MeteoSwiss ICON Seamless',
resolution: '1-2 km',
update: 'every 3 h'
},
{ {
value: 'meteoswiss_icon_ch1', value: 'meteoswiss_icon_ch1',
label: 'MeteoSwiss ICON CH1', label: 'MeteoSwiss ICON CH1',
@@ -228,25 +232,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', value: 'jma',
label: 'JMA Japan', label: 'JMA Japan',
models: [ 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_msm', label: 'JMA MSM', resolution: '5 km', update: 'every 3 h' },
{ value: 'jma_gsm', label: 'JMA GSM', resolution: '55 km', update: 'every 6 h' } { value: 'jma_gsm', label: 'JMA GSM', resolution: '55 km', update: 'every 6 h' }
] ]
@@ -263,23 +252,10 @@ export const modelGroups: WeatherModelGroup[] = [
} }
] ]
}, },
{
value: 'bom',
label: 'BOM Australia',
models: [
{
value: 'bom_access_global',
label: 'BOM ACCESS Global',
resolution: '15 km',
update: 'every 12 h'
}
]
},
{ {
value: 'cmc_gem', value: 'cmc_gem',
label: 'GEM Canada', label: 'GEM Canada',
models: [ 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_global', label: 'GEM Global', resolution: '15 km', update: 'every 12 h' },
{ value: 'gem_regional', label: 'GEM Regional', resolution: '10 km', update: 'every 6 h' }, { value: 'gem_regional', label: 'GEM Regional', resolution: '10 km', update: 'every 6 h' },
{ {
@@ -433,11 +409,6 @@ export const ensembleModelGroups: WeatherModelGroup[] = [
} }
] ]
}, },
{
value: 'bom',
label: 'BOM Australia',
models: [{ value: 'bom_access_global_ensemble', label: 'BOM ACCESS Global' }]
},
{ {
value: 'ukmo', value: 'ukmo',
label: 'UK Met Office', label: 'UK Met Office',
@@ -503,9 +474,7 @@ const IN_DOMAIN_CITY: [string, { slug: string; label: string }][] = [
['nbm', { slug: 'new-york', label: 'New York' }], ['nbm', { slug: 'new-york', label: 'New York' }],
['gem', { slug: 'toronto', label: 'Toronto' }], ['gem', { slug: 'toronto', label: 'Toronto' }],
['jma', { slug: 'tokyo', label: 'Tokyo' }], ['jma', { slug: 'tokyo', label: 'Tokyo' }],
['kma', { slug: 'seoul', label: 'Seoul' }], ['cma', { slug: 'beijing', label: 'Beijing' }]
['cma', { slug: 'beijing', label: 'Beijing' }],
['bom', { slug: 'sydney', label: 'Sydney' }]
]; ];
/** Where this model definitely has data, or null for a global model. */ /** 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). // These conditions ship only as a single neutral glyph (no day/night variant).
const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']); 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 { export function getWeatherIconName(code: number, daytime: boolean): string {
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear'; const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`; if (NEUTRAL_ICONS.has(name)) return `wi-${name}`;