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