24 Commits
Author SHA1 Message Date
Vincent van der Wal d2253d002d align 2026-08-10 09:22:58 +02:00
vincent f186c3ff9a Merge pull request 'feat: multimodel improvements' (#9) from multimodel-improvements into main
Reviewed-on: #9
2026-08-10 09:15:58 +02:00
Vincent van der Wal 3f9f1df8f0 merge main 2026-08-10 09:15:04 +02:00
Vincent van der Wal 032b652675 park 2026-08-07 10:58:30 +02:00
Vincent van der Wal 83b0e46e64 finish animations and add elevation 2026-08-06 22:19:17 +02:00
Vincent van der Wal d0f94094ca last fade attempt 2026-08-06 21:37:28 +02:00
Vincent van der Wal 810f9c2605 map fade 2026-08-06 21:25:29 +02:00
Vincent van der Wal c4a74ba91c flashing fixed 2026-08-06 21:23:59 +02:00
Vincent van der Wal c3bb6de49c home loading bug and flashes fixes 2026-08-06 21:10:15 +02:00
Vincent van der Wal 1c5ea2b52e beter crossfade 2026-08-06 19:24:22 +02:00
Vincent van der Wal f4a02a208c overlay 2026-08-06 19:11:11 +02:00
terraputix 506831cf88 mobile improvements mostly 2026-08-03 23:14:23 +02:00
terraputix 39f36819ae selection in page 2026-08-03 22:33:54 +02:00
terraputix b53eb77c75 fix: reapply hourly weather_code 2026-08-03 22:12:55 +02:00
terraputix b50eee634e avoid multimodel update loop 2026-08-03 21:57:36 +02:00
Vincent van der Wal 025623aaa6 rm unused urlparam 2026-08-03 13:26:30 +02:00
Vincent van der Wal dbfa38f6c5 read more text 2026-08-03 11:39:56 +02:00
Vincent van der Wal 9fc17e7417 fix incons 2026-08-03 11:05:34 +02:00
Vincent van der Wal f66e259e32 add seasonal 2026-08-03 09:57:33 +02:00
terraputix 8b58bc01d7 review fixes 2026-08-02 23:05:09 +02:00
terraputix edee9fe33d heading improvement 2026-08-02 22:45:52 +02:00
terraputix 5541df20e1 add aladin models 2026-08-02 22:42:38 +02:00
terraputix 77af339df4 redesign model and variable selection 2026-08-02 22:35:36 +02:00
terraputix ea9eb17d75 multimodel improvements 2026-08-02 22:24:51 +02:00
68 changed files with 4465 additions and 1355 deletions
+13
View File
@@ -33,3 +33,16 @@ AGENTS.md
# Claude Code scratch: throwaway probe scripts, never committed
/.scratch
# Empty stubs the Claude Code sandbox mounts over shell/editor dotfiles while it
# runs. They are not project files and keep sneaking into commits via `git add -A`.
/.bash_profile
/.bashrc
/.profile
/.zprofile
/.zshrc
/.gitconfig
/.gitmodules
/.ripgreprc
/.idea
/.mcp.json
+9 -2
View File
@@ -46,6 +46,12 @@ Pages that are not prerendered (unlisted cities, GPS coordinate routes like
and resolves the location client-side. Configure the server to serve
`404.html` for unknown paths.
Serve it as an **internal rewrite (200)**, not as an error page. `error_page
404 /404.html` sends the right body with a 404 status: the page works, but
every hard reload of an unprerendered URL logs a 404 in the network panel and
tells crawlers the page does not exist. `try_files` with a URI as its last
argument does an internal redirect instead, and answers 200.
### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
@@ -83,11 +89,12 @@ drizzli.example.com {
server {
server_name drizzli.example.com;
root /srv/drizzli;
error_page 404 /404.html;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
location / {
try_files $uri $uri/ =404;
# the trailing /404.html is a URI, so nginx rewrites internally and
# answers 200 - `error_page 404 /404.html` would answer 404 instead
try_files $uri $uri/index.html /404.html;
}
}
```
+88 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Tage-Vorhersage",
"nav_week": "Wochenvorhersage",
"nav_compare": "Modellvergleich",
"nav_14day": "14-Tage-Vorhersage",
"nav_seasonal": "Saisonal",
@@ -44,7 +44,7 @@
"day_today": "Heute",
"day_tomorrow": "Morgen",
"day_yesterday": "Gestern",
"page_week_subtitle": "7-Tage-Vorhersage",
"page_week_subtitle": "Wochenvorhersage",
"page_compare_subtitle": "Modellvergleich",
"page_14day_subtitle": "14-Tage-Ensemblevorhersage",
"page_seasonal_subtitle": "Saisonale Aussichten",
@@ -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",
@@ -260,6 +260,8 @@
"table_interval_aria": "Stundenintervall",
"table_now": "Jetzt",
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
"page_loading": "Wird geladen…",
"page_loading_dismiss": "Schließen",
"charts_loading": "Diagramme werden geladen…",
"chart_download": "Meteogramm als PNG-Bild herunterladen",
"chart_credit_viz": "Visualisierung von",
@@ -267,7 +269,7 @@
"legend_show": "Legende anzeigen",
"meteograms_none_before": "Keine Meteogramme konfiguriert,",
"meteograms_none_action": "fügen Sie Variablen hinzu",
"meteograms_none_historical": "Keine Meteogramme konfiguriert. Fügen Sie Variablen auf der 7-Tage-Seite hinzu.",
"meteograms_none_historical": "Keine Meteogramme konfiguriert. Fügen Sie Variablen auf der Wochenvorhersage-Seite hinzu.",
"search_searching": "Wird gesucht…",
"search_favorites": "Favoriten",
"search_recent": "Zuletzt",
@@ -280,9 +282,60 @@
"model_automatic_selection": "Automatische Auswahl",
"model_selector_aria": "Auswahl: {label}",
"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",
@@ -335,5 +388,35 @@
"cadence_varies": "variiert",
"model_group_automatic": "Automatisch",
"model_group_reanalysis": "ECMWF-Reanalyse",
"model_group_regional_reanalysis": "Regionale Reanalyse"
"model_group_regional_reanalysis": "Regionale Reanalyse",
"wmo_0": "Klarer Himmel",
"wmo_1": "Überwiegend klar",
"wmo_2": "Teils bewölkt",
"wmo_3": "Bedeckt",
"wmo_45": "Nebel",
"wmo_48": "Gefrierender Nebel",
"wmo_51": "Leichter Nieselregen",
"wmo_53": "Mäßiger Nieselregen",
"wmo_55": "Starker Nieselregen",
"wmo_56": "Leichter gefrierender Nieselregen",
"wmo_57": "Starker gefrierender Nieselregen",
"wmo_61": "Leichter Regen",
"wmo_63": "Mäßiger Regen",
"wmo_65": "Starker Regen",
"wmo_66": "Leichter gefrierender Regen",
"wmo_67": "Starker gefrierender Regen",
"wmo_71": "Leichter Schneefall",
"wmo_73": "Mäßiger Schneefall",
"wmo_75": "Starker Schneefall",
"wmo_77": "Schneegriesel",
"wmo_80": "Leichte Regenschauer",
"wmo_81": "Mäßige Regenschauer",
"wmo_82": "Heftige Regenschauer",
"wmo_85": "Leichte Schneeschauer",
"wmo_86": "Starke Schneeschauer",
"wmo_95": "Gewitter",
"wmo_96": "Gewitter mit leichtem Hagel",
"wmo_99": "Gewitter mit starkem Hagel",
"summary_read_more": "Mehr anzeigen",
"summary_read_less": "Weniger anzeigen"
}
+88 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Day Forecast",
"nav_week": "Weekly Forecast",
"nav_compare": "Model Comparison",
"nav_14day": "14-Day Forecast",
"nav_seasonal": "Seasonal",
@@ -44,7 +44,7 @@
"day_today": "Today",
"day_tomorrow": "Tomorrow",
"day_yesterday": "Yesterday",
"page_week_subtitle": "7-day forecast",
"page_week_subtitle": "Weekly forecast",
"page_compare_subtitle": "Model comparison",
"page_14day_subtitle": "14-day ensemble forecast",
"page_seasonal_subtitle": "Seasonal outlook",
@@ -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",
@@ -260,6 +260,8 @@
"table_interval_aria": "Hourly interval",
"table_now": "Now",
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
"page_loading": "Loading…",
"page_loading_dismiss": "Dismiss",
"charts_loading": "Loading charts...",
"chart_download": "Download meteogram as PNG image",
"chart_credit_viz": "visualisation by",
@@ -267,7 +269,7 @@
"legend_show": "Show legend",
"meteograms_none_before": "No meteograms configured,",
"meteograms_none_action": "add some variables",
"meteograms_none_historical": "No meteograms configured. Add variables from the 7-day forecast page.",
"meteograms_none_historical": "No meteograms configured. Add variables from the weekly forecast page.",
"search_searching": "Searching...",
"search_favorites": "Favorites",
"search_recent": "Recent",
@@ -280,9 +282,60 @@
"model_automatic_selection": "Automatic selection",
"model_selector_aria": "{label} selection",
"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",
@@ -335,5 +388,35 @@
"cadence_varies": "varies",
"model_group_automatic": "Automatic",
"model_group_reanalysis": "ECMWF reanalysis",
"model_group_regional_reanalysis": "Regional reanalysis"
"model_group_regional_reanalysis": "Regional reanalysis",
"wmo_0": "Clear sky",
"wmo_1": "Mainly clear",
"wmo_2": "Partly cloudy",
"wmo_3": "Overcast",
"wmo_45": "Fog",
"wmo_48": "Depositing rime fog",
"wmo_51": "Light drizzle",
"wmo_53": "Moderate drizzle",
"wmo_55": "Dense drizzle",
"wmo_56": "Light freezing drizzle",
"wmo_57": "Dense freezing drizzle",
"wmo_61": "Slight rain",
"wmo_63": "Moderate rain",
"wmo_65": "Heavy rain",
"wmo_66": "Light freezing rain",
"wmo_67": "Heavy freezing rain",
"wmo_71": "Slight snowfall",
"wmo_73": "Moderate snowfall",
"wmo_75": "Heavy snowfall",
"wmo_77": "Snow grains",
"wmo_80": "Slight rain showers",
"wmo_81": "Moderate rain showers",
"wmo_82": "Violent rain showers",
"wmo_85": "Slight snow showers",
"wmo_86": "Heavy snow showers",
"wmo_95": "Thunderstorm",
"wmo_96": "Thunderstorm with slight hail",
"wmo_99": "Thunderstorm with heavy hail",
"summary_read_more": "Read more",
"summary_read_less": "Show less"
}
+91 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Pronóstico de 7 días",
"nav_week": "Pronóstico semanal",
"nav_compare": "Comparación de modelos",
"nav_14day": "Pronóstico de 14 días",
"nav_seasonal": "Estacional",
@@ -44,11 +44,11 @@
"day_today": "Hoy",
"day_tomorrow": "Mañana",
"day_yesterday": "Ayer",
"page_week_subtitle": "pronóstico de 7 días",
"page_compare_subtitle": "comparación de modelos",
"page_14day_subtitle": "pronóstico de conjunto a 14 días",
"page_seasonal_subtitle": "previsión estacional",
"page_historical_subtitle": "clima histórico",
"page_week_subtitle": "Pronóstico semanal",
"page_compare_subtitle": "Comparación de modelos",
"page_14day_subtitle": "Pronóstico de conjunto a 14 días",
"page_seasonal_subtitle": "Previsión estacional",
"page_historical_subtitle": "Clima histórico",
"hourly_heading": "por horas",
"hourly_variables": "Variables",
"meteograms_heading": "Meteogramas",
@@ -260,6 +260,8 @@
"table_interval_aria": "Intervalo horario",
"table_now": "Ahora",
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
"page_loading": "Cargando…",
"page_loading_dismiss": "Descartar",
"charts_loading": "Cargando gráficos…",
"chart_download": "Descargar el meteograma como imagen PNG",
"chart_credit_viz": "visualización de",
@@ -267,7 +269,7 @@
"legend_show": "Mostrar leyenda",
"meteograms_none_before": "No hay meteogramas configurados,",
"meteograms_none_action": "añade algunas variables",
"meteograms_none_historical": "No hay meteogramas configurados. Añade variables desde la página de 7 días.",
"meteograms_none_historical": "No hay meteogramas configurados. Añade variables desde la página del pronóstico semanal.",
"search_searching": "Buscando…",
"search_favorites": "Favoritos",
"search_recent": "Recientes",
@@ -280,9 +282,60 @@
"model_automatic_selection": "Selección automática",
"model_selector_aria": "Selección de {label}",
"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",
@@ -335,5 +388,35 @@
"cadence_varies": "variable",
"model_group_automatic": "Automático",
"model_group_reanalysis": "Reanálisis del ECMWF",
"model_group_regional_reanalysis": "Reanálisis regional"
"model_group_regional_reanalysis": "Reanálisis regional",
"wmo_0": "Cielo despejado",
"wmo_1": "Mayormente despejado",
"wmo_2": "Parcialmente nublado",
"wmo_3": "Cubierto",
"wmo_45": "Niebla",
"wmo_48": "Niebla engelante",
"wmo_51": "Llovizna débil",
"wmo_53": "Llovizna moderada",
"wmo_55": "Llovizna intensa",
"wmo_56": "Llovizna engelante débil",
"wmo_57": "Llovizna engelante intensa",
"wmo_61": "Lluvia débil",
"wmo_63": "Lluvia moderada",
"wmo_65": "Lluvia intensa",
"wmo_66": "Lluvia engelante débil",
"wmo_67": "Lluvia engelante intensa",
"wmo_71": "Nevada débil",
"wmo_73": "Nevada moderada",
"wmo_75": "Nevada intensa",
"wmo_77": "Granos de nieve",
"wmo_80": "Chubascos débiles",
"wmo_81": "Chubascos moderados",
"wmo_82": "Chubascos violentos",
"wmo_85": "Chubascos de nieve débiles",
"wmo_86": "Chubascos de nieve intensos",
"wmo_95": "Tormenta",
"wmo_96": "Tormenta con granizo ligero",
"wmo_99": "Tormenta con granizo fuerte",
"summary_read_more": "Leer más",
"summary_read_less": "Mostrar menos"
}
+91 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Prévisions à 7 jours",
"nav_week": "Prévisions hebdomadaires",
"nav_compare": "Comparaison des modèles",
"nav_14day": "Prévisions à 14 jours",
"nav_seasonal": "Saisonnier",
@@ -44,11 +44,11 @@
"day_today": "Aujourd'hui",
"day_tomorrow": "Demain",
"day_yesterday": "Hier",
"page_week_subtitle": "prévisions à 7 jours",
"page_compare_subtitle": "comparaison des modèles",
"page_14day_subtitle": "prévision d'ensemble à 14 jours",
"page_seasonal_subtitle": "tendances saisonnières",
"page_historical_subtitle": "météo historique",
"page_week_subtitle": "Prévisions hebdomadaires",
"page_compare_subtitle": "Comparaison des modèles",
"page_14day_subtitle": "Prévision d'ensemble à 14 jours",
"page_seasonal_subtitle": "Tendances saisonnières",
"page_historical_subtitle": "Météo historique",
"hourly_heading": "par heure",
"hourly_variables": "Variables",
"meteograms_heading": "Météogrammes",
@@ -260,6 +260,8 @@
"table_interval_aria": "Intervalle horaire",
"table_now": "Maintenant",
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
"page_loading": "Chargement…",
"page_loading_dismiss": "Fermer",
"charts_loading": "Chargement des graphiques…",
"chart_download": "Télécharger le météogramme en PNG",
"chart_credit_viz": "visualisation par",
@@ -267,7 +269,7 @@
"legend_show": "Afficher la légende",
"meteograms_none_before": "Aucun météogramme configuré,",
"meteograms_none_action": "ajoutez des variables",
"meteograms_none_historical": "Aucun météogramme configuré. Ajoutez des variables depuis la page 7 jours.",
"meteograms_none_historical": "Aucun météogramme configuré. Ajoutez des variables depuis la page des prévisions hebdomadaires.",
"search_searching": "Recherche…",
"search_favorites": "Favoris",
"search_recent": "Récents",
@@ -280,9 +282,60 @@
"model_automatic_selection": "Sélection automatique",
"model_selector_aria": "Sélection : {label}",
"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 nimporte quel lieu.",
"compare_direction_subtitle": "Dispersion des directions sur {count} modèles · sans moyenne",
"compare_scalar_subtitle": "Sur {count} modèles · pointillés = moyenne des modèles",
"compare_precipitation_subtitle": "Sur {count} modèles · bande inférieure = accord sur les précipitations",
"compare_precipitation_agreement": "Accord",
"compare_precipitation_agreement_tooltip": "{wet}/{available} avec précipitations · médiane {median} {unit} · plage {min}{max} {unit}",
"compare_model_mean": "Moyenne des modèles",
"compare_showing_previous": "La dernière comparaison réussie reste affichée.",
"compare_empty_selection": "Sélectionnez au moins un modèle et une variable météo.",
"compare_no_data": "Aucun des modèles sélectionnés ne fournit de données exploitables pour ce lieu et cette sélection.",
"compare_weather_codes_only": "Les conditions et la couverture nuageuse sont affichées dans la chronologie ci-dessous. Sélectionnez une autre variable pour ajouter un graphique.",
"compare_model_colors": "Couleurs des modèles",
"compare_group_temperature": "Température et humidité",
"compare_group_precipitation": "Précipitations et conditions",
"compare_group_clouds": "Pression et nuages",
"compare_group_wind": "Vent et atmosphère",
"compare_group_upper_air": "Température en altitude",
"compare_many_models_warning": "{count} modèles sont sélectionnés. La comparaison peut être plus lente et les couleurs plus difficiles à distinguer.",
"compare_timeline_hint": "Conditions par modèle ; le fond des pictogrammes indique la couverture nuageuse totale",
"compare_timeline_scroll_aria": "Chronologie météo des modèles à défilement horizontal",
"compare_timeline_caption": "Conditions météo horaires par modèle de prévision",
"ensemble_trimmed": "L'ensemble de ce modèle ne va qu'à environ {days} jours, la dispersion est donc limitée à sa portée disponible.",
"seasonal_explainer_before": "Une prévision saisonnière montre dans quelle mesure un mois entier devrait",
"seasonal_explainer_strong": "s'écarter de sa normale climatique",
@@ -335,5 +388,35 @@
"cadence_varies": "variable",
"model_group_automatic": "Automatique",
"model_group_reanalysis": "Réanalyse ECMWF",
"model_group_regional_reanalysis": "Réanalyse régionale"
"model_group_regional_reanalysis": "Réanalyse régionale",
"wmo_0": "Ciel dégagé",
"wmo_1": "Plutôt dégagé",
"wmo_2": "Partiellement nuageux",
"wmo_3": "Couvert",
"wmo_45": "Brouillard",
"wmo_48": "Brouillard givrant",
"wmo_51": "Bruine faible",
"wmo_53": "Bruine modérée",
"wmo_55": "Bruine dense",
"wmo_56": "Bruine verglaçante faible",
"wmo_57": "Bruine verglaçante dense",
"wmo_61": "Pluie faible",
"wmo_63": "Pluie modérée",
"wmo_65": "Pluie forte",
"wmo_66": "Pluie verglaçante faible",
"wmo_67": "Pluie verglaçante forte",
"wmo_71": "Neige faible",
"wmo_73": "Neige modérée",
"wmo_75": "Neige forte",
"wmo_77": "Grésil",
"wmo_80": "Averses faibles",
"wmo_81": "Averses modérées",
"wmo_82": "Averses violentes",
"wmo_85": "Averses de neige faibles",
"wmo_86": "Averses de neige fortes",
"wmo_95": "Orage",
"wmo_96": "Orage avec grêle légère",
"wmo_99": "Orage avec forte grêle",
"summary_read_more": "Lire la suite",
"summary_read_less": "Afficher moins"
}
+91 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Previsioni a 7 giorni",
"nav_week": "Previsioni settimanali",
"nav_compare": "Confronto modelli",
"nav_14day": "Previsioni a 14 giorni",
"nav_seasonal": "Stagionale",
@@ -44,11 +44,11 @@
"day_today": "Oggi",
"day_tomorrow": "Domani",
"day_yesterday": "Ieri",
"page_week_subtitle": "previsioni a 7 giorni",
"page_compare_subtitle": "confronto modelli",
"page_14day_subtitle": "previsione d'insieme a 14 giorni",
"page_seasonal_subtitle": "tendenze stagionali",
"page_historical_subtitle": "meteo storico",
"page_week_subtitle": "Previsioni settimanali",
"page_compare_subtitle": "Confronto modelli",
"page_14day_subtitle": "Previsione d'insieme a 14 giorni",
"page_seasonal_subtitle": "Tendenze stagionali",
"page_historical_subtitle": "Meteo storico",
"hourly_heading": "orario",
"hourly_variables": "Variabili",
"meteograms_heading": "Meteogrammi",
@@ -260,6 +260,8 @@
"table_interval_aria": "Intervallo orario",
"table_now": "Ora",
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
"page_loading": "Caricamento…",
"page_loading_dismiss": "Chiudi",
"charts_loading": "Caricamento dei grafici…",
"chart_download": "Scarica il meteogramma come immagine PNG",
"chart_credit_viz": "visualizzazione di",
@@ -267,7 +269,7 @@
"legend_show": "Mostra legenda",
"meteograms_none_before": "Nessun meteogramma configurato,",
"meteograms_none_action": "aggiungi qualche variabile",
"meteograms_none_historical": "Nessun meteogramma configurato. Aggiungi variabili dalla pagina a 7 giorni.",
"meteograms_none_historical": "Nessun meteogramma configurato. Aggiungi variabili dalla pagina delle previsioni settimanali.",
"search_searching": "Ricerca in corso…",
"search_favorites": "Preferiti",
"search_recent": "Recenti",
@@ -280,9 +282,60 @@
"model_automatic_selection": "Selezione automatica",
"model_selector_aria": "Selezione di {label}",
"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 unaltra variabile per aggiungere un grafico.",
"compare_model_colors": "Colori dei modelli",
"compare_group_temperature": "Temperatura e umidità",
"compare_group_precipitation": "Precipitazioni e condizioni",
"compare_group_clouds": "Pressione e nuvole",
"compare_group_wind": "Vento e atmosfera",
"compare_group_upper_air": "Temperatura in quota",
"compare_many_models_warning": "Sono selezionati {count} modelli. Il confronto può essere più lento e i colori più difficili da distinguere.",
"compare_timeline_hint": "Condizioni per modello; lo sfondo dei pittogrammi indica la copertura nuvolosa totale",
"compare_timeline_scroll_aria": "Cronologia meteo scorrevole dei modelli",
"compare_timeline_caption": "Condizioni meteo orarie per modello di previsione",
"ensemble_trimmed": "L'ensemble di questo modello arriva solo a circa {days} giorni, quindi la dispersione è ridotta al periodo disponibile.",
"seasonal_explainer_before": "Una previsione stagionale mostra quanto un intero mese potrebbe",
"seasonal_explainer_strong": "discostarsi dalla sua normale climatica",
@@ -335,5 +388,35 @@
"cadence_varies": "variabile",
"model_group_automatic": "Automatico",
"model_group_reanalysis": "Rianalisi ECMWF",
"model_group_regional_reanalysis": "Rianalisi regionale"
"model_group_regional_reanalysis": "Rianalisi regionale",
"wmo_0": "Cielo sereno",
"wmo_1": "Prevalentemente sereno",
"wmo_2": "Parzialmente nuvoloso",
"wmo_3": "Coperto",
"wmo_45": "Nebbia",
"wmo_48": "Nebbia gelata",
"wmo_51": "Pioviggine debole",
"wmo_53": "Pioviggine moderata",
"wmo_55": "Pioviggine intensa",
"wmo_56": "Pioviggine congelantesi debole",
"wmo_57": "Pioviggine congelantesi intensa",
"wmo_61": "Pioggia debole",
"wmo_63": "Pioggia moderata",
"wmo_65": "Pioggia forte",
"wmo_66": "Pioggia congelantesi debole",
"wmo_67": "Pioggia congelantesi forte",
"wmo_71": "Nevicata debole",
"wmo_73": "Nevicata moderata",
"wmo_75": "Nevicata forte",
"wmo_77": "Granuli di neve",
"wmo_80": "Rovesci deboli",
"wmo_81": "Rovesci moderati",
"wmo_82": "Rovesci violenti",
"wmo_85": "Rovesci di neve deboli",
"wmo_86": "Rovesci di neve forti",
"wmo_95": "Temporale",
"wmo_96": "Temporale con grandine leggera",
"wmo_99": "Temporale con grandine forte",
"summary_read_more": "Leggi tutto",
"summary_read_less": "Mostra meno"
}
-42
View File
@@ -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": [
+5
View File
@@ -3,6 +3,11 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Declared in the shell, not in the layout head: the SPA fallback page
(404.html) ships no rendered head, so without this the browser falls
back to requesting /favicon.ico and takes a 404 on every load of an
unprerendered URL. -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<script>
// apply the persisted theme before first paint to avoid a flash
try {
+263 -43
View File
@@ -24,13 +24,15 @@
/** Series display name (used in legend and tooltip) */
name: string;
/** Render style */
type: 'line' | 'bar';
type: 'line' | 'bar' | 'point';
/** Any CSS color string */
color: string;
/** One value per timestamp; null values break lines */
data: (number | null)[];
/** Line width in px (default 2); 0 draws only the area fill */
width?: number;
/** Radius in px for point series (default 3). */
pointRadius?: number;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
fill?: boolean;
/** Area fill coloured by the value scale (segmentColor), fading to
@@ -74,6 +76,20 @@
cloudLayer?: 'high' | 'mid' | 'low';
}
export interface ChartAgreementPoint {
wetCount: number;
availableCount: number;
median: number;
min: number;
max: number;
}
export interface ChartAgreementStrip {
points: (ChartAgreementPoint | null)[];
label: string;
format: (point: ChartAgreementPoint) => string;
}
interface GroupState {
range: { start: number; end: number } | null;
hover: number | null;
@@ -101,6 +117,17 @@
}
}
/** Register a non-chart surface that participates in synchronized interactions. */
export function registerGroupMember(name: string): () => void {
acquireGroup(name);
let registered = true;
return () => {
if (!registered) return;
registered = false;
releaseGroup(name);
};
}
/**
* Drive the shared crosshair of a chart group from the outside (e.g. hovering
* the hourly table). `time` is epoch seconds, or null to clear. No-op if no
@@ -108,7 +135,16 @@
*/
export function setGroupHover(name: string, time: number | null): void {
const state = groups[name];
if (state) state.hover = time;
if (state && state.hover !== time) state.hover = time;
}
/** Set the synchronized visible time range for a registered chart group. */
export function setGroupRange(
name: string,
range: { start: number; end: number } | null
): void {
const state = groups[name];
if (state) state.range = range;
}
/** Current shared zoom range of a group (null = full range), reactive. */
@@ -151,6 +187,8 @@
windArrows?: { t: number; deg: number }[];
/** Highlighted time range (epoch seconds), e.g. the selected day */
highlight?: { start: number; end: number };
/** Compact per-timestamp model-agreement band below the plot. */
agreementStrip?: ChartAgreementStrip;
/** Unit label for the left y axis (also used in tooltip values) */
unit?: string;
/** Unit label for the right y axis; when set, right-axis labels are drawn */
@@ -167,6 +205,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)
);
}
}
ctx.restore();
// 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}
@@ -1713,7 +1922,7 @@
{/if}
<!-- Wind-direction arrows: a matching band -->
{#if visibleWindArrows.length > 0}
{#if visibleWindArrows.length > 0}
<div
class="pointer-events-none absolute z-10 overflow-hidden rounded-t-lg border border-border/60 bg-muted/30"
style:left="{iconBandLeft}px"
@@ -1736,9 +1945,20 @@
</span>
{/each}
</div>
{/if}
{/if}
{#if tooltipVisible}
<!-- 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"
style:top="{padTop + 8}px"
+9 -10
View File
@@ -33,19 +33,19 @@ export const CHART_COLORS = {
// ─── Utility: Detect column-type variables ───────────────────────────────────
/** Units that should be rendered as bar/column charts instead of lines. */
const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
const COLUMN_UNITS = new Set(['mm', 'cm', 'in', 'inch', 'MJ/m²']);
/**
* Returns true if the given unit should be rendered as a bar chart.
*/
export function isColumnUnit(unit: string): boolean {
return COLUMN_UNITS.has(unit);
return COLUMN_UNITS.has(unit.trim());
}
// ─── Data Processing Helpers ─────────────────────────────────────────────────
export interface AverageResult {
average: number[];
average: (number | null)[];
averageCount: number[];
}
@@ -62,7 +62,7 @@ export function calculateAverage(
variable: string,
timeLength: number
): AverageResult {
const average = new Array<number>(timeLength).fill(0);
const totals = new Array<number>(timeLength).fill(0);
const averageCount = new Array<number>(timeLength).fill(0);
for (const [model, values] of Object.entries(hourlyData)) {
@@ -71,18 +71,17 @@ export function calculateAverage(
for (const [index, val] of (values as number[]).entries()) {
if (val !== null && val !== undefined && isFinite(val)) {
average[index] += val;
if (index >= timeLength) continue;
totals[index] += val;
averageCount[index]++;
}
}
}
// Finalize average values
for (let i = 0; i < timeLength; i++) {
if (averageCount[i] > 0) {
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
}
}
const average = totals.map((total, i) =>
averageCount[i] > 0 ? Math.round((total / averageCount[i]) * 10) / 10 : null
);
return { average, averageCount };
}
+7 -1
View File
@@ -8,10 +8,16 @@
export {
default as CanvasChart,
setGroupHover,
setGroupRange,
registerGroupMember,
groupRange,
groupHover
} from './CanvasChart.svelte';
export type { ChartSeries } from './CanvasChart.svelte';
export type {
ChartAgreementPoint,
ChartAgreementStrip,
ChartSeries
} from './CanvasChart.svelte';
export { buildDaylightBands } from './bands';
export type { DaylightBand } from './bands';
+15 -9
View File
@@ -18,14 +18,15 @@
{/snippet}
</ChartToolbar>
-->
<script module lang="ts">
<script lang="ts">
import * as m from '$lib/paraglide/messages';
export type { ExportableChart } from './downloadChartsPng';
</script>
<script lang="ts">
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
import {
type ChartDownloadOptions,
type ChartExportItem,
type ExportableChart,
downloadChartsPng
} from './downloadChartsPng';
import type { Snippet } from 'svelte';
@@ -33,9 +34,11 @@
interface Props {
/** Chart components available for download (undefined entries are skipped) */
charts?: Array<ExportableChart | undefined | null>;
charts?: Array<ChartExportItem | ExportableChart | undefined | null>;
/** Base file name for downloaded images (without extension) */
fileName?: string;
/** Optional title and shared legend drawn into the combined PNG. */
exportOptions?: ChartDownloadOptions;
/** Optional CSS class for the outer container */
class?: string;
/** Slot for additional controls (switches, checkboxes, etc.) */
@@ -45,6 +48,7 @@
let {
charts = [],
fileName = 'drizzli-chart',
exportOptions,
class: className = '',
controls
}: Props = $props();
@@ -55,7 +59,9 @@
// ─── Computed ───────────────────────────────────────────────────────────────
let hasCharts = $derived(charts.some((chart) => chart != null));
let hasCharts = $derived(
charts.some((item) => item != null && ('chart' in item ? item.chart != null : true))
);
// ─── Download ───────────────────────────────────────────────────────────────
@@ -63,7 +69,7 @@
if (!hasCharts || downloading) return;
downloading = true;
try {
await downloadChartsPng(charts, fileName);
await downloadChartsPng(charts, fileName, exportOptions);
} finally {
setTimeout(() => {
downloading = false;
+101 -6
View File
@@ -17,6 +17,17 @@ export interface ChartExportItem {
title?: string;
}
export interface ExportLegendItem {
name: string;
color: string;
style?: 'point' | 'line' | 'dashed' | 'bar';
}
export interface ChartDownloadOptions {
title?: string;
legend?: ExportLegendItem[];
}
/** Resolves the page background so exports match the current theme. */
function exportBackground(): string {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
@@ -47,18 +58,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`);
}
+7 -1
View File
@@ -9,4 +9,10 @@
export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.svelte';
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
export {
downloadChartsPng,
type ChartDownloadOptions,
type ChartExportItem,
type ExportableChart,
type ExportLegendItem
} from './downloadChartsPng';
+27 -10
View File
@@ -14,7 +14,8 @@
import { href, routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
import SupporterBadge from '$lib/supporter/SupporterBadge.svelte';
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
import SettingsMenu from './settings-menu.svelte';
import ThemeIcon from './theme-icon.svelte';
@@ -41,6 +42,21 @@
if (flagEl && !flagEl.src.endsWith(src)) flagEl.src = src;
});
// Built as a string rather than inline markup: the pieces are optional, and
// separators spelled out in the template lose their spacing to Svelte's
// whitespace trimming ("Canton of Schwyz,Switzerland").
let locationRegion = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
// elevation rides along in the pill's muted part ("· Canton of Schwyz,
// Switzerland · 465m"); a 0 m coastal town is a real reading, only a
// missing value is dropped
let locationDetail = $derived(
[locationRegion, location?.elevation != null ? `${Math.round(location.elevation)}m` : null]
.filter(Boolean)
.join(' · ')
);
// the pill ellipses, so the full name still has to be readable somewhere
let locationLine = $derived([location?.name, locationDetail].filter(Boolean).join(' · '));
const themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, () => string> = {
system: m.theme_follow_system,
@@ -99,8 +115,10 @@
<!-- Current location display -->
{#if location}
<!-- min-w-0 + a ceiling so a long "Sant Pere de Ribes, Catalonia, Spain"
ellipses inside the pill instead of pushing the search box off centre -->
<div
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
class="hidden min-w-0 max-w-70 items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex xl:max-w-96"
>
<img
bind:this={flagEl}
@@ -109,13 +127,10 @@
alt={location.country}
/>
<!-- full location (desktop); the page hero carries it on smaller screens -->
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
<span class="min-w-0 truncate text-sm font-semibold text-foreground" title={locationLine}>
{location.name}
{#if location.admin1 || location.country}
<span class="font-normal text-muted-foreground">
· {#if location.admin1}{location.admin1},&nbsp;
{/if}{location.country ?? ''}
</span>
{#if locationDetail}
<span class="font-normal text-muted-foreground">· {locationDetail}</span>
{/if}
</span>
</div>
@@ -144,8 +159,10 @@
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
so SupporterBadge still verifies the key on every viewport. -->
<div class="hidden items-center gap-3 md:flex">
<!-- Supporter status / unlock -->
<SupporterBadge />
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
{#if SUPPORTER_ENABLED}
<SupporterBadge />
{/if}
<!-- Language: the locale lives in the URL, so this is a set of links -->
<LanguageSelector />
@@ -6,9 +6,10 @@
import UnitOptions from '$lib/components/unit-options.svelte';
import * as m from '$lib/paraglide/messages';
import SupporterIcon from '$lib/paywall/SupporterIcon.svelte';
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import SupporterIcon from '$lib/supporter/SupporterIcon.svelte';
import UnlockDialog from '$lib/supporter/UnlockDialog.svelte';
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
import { isSupporter } from '$lib/supporter/store';
import ThemeIcon from './theme-icon.svelte';
@@ -76,29 +77,34 @@
<LanguageOptions onSelect={() => (open = false)} />
<div class="border-t border-border/70 pt-3">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
onclick={() => {
open = false;
unlockOpen = true;
}}
>
<SupporterIcon
filled={$isSupporter}
class="h-4.5 w-4.5 shrink-0 {$isSupporter ? 'text-amber-500' : 'text-muted-foreground'}"
/>
<span class="min-w-0 flex-1">
<span class="block text-[13px] font-semibold">
{$isSupporter ? m.supporter_active() : m.supporter_support()}
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
{#if SUPPORTER_ENABLED}
<div class="border-t border-border/70 pt-3">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
onclick={() => {
open = false;
unlockOpen = true;
}}
>
<SupporterIcon
filled={$isSupporter}
class="h-4.5 w-4.5 shrink-0 {$isSupporter
? 'text-amber-500'
: 'text-muted-foreground'}"
/>
<span class="min-w-0 flex-1">
<span class="block text-[13px] font-semibold">
{$isSupporter ? m.supporter_active() : m.supporter_support()}
</span>
<span class="block text-[11px] text-muted-foreground">
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
</span>
</span>
<span class="block text-[11px] text-muted-foreground">
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
</span>
</span>
</button>
</div>
</button>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
+34 -133
View File
@@ -10,6 +10,7 @@
* - Efficient binary protobuf transport instead of JSON
* - Consistent timestamp and unit handling
*/
import { Model } from '@openmeteo/sdk/model';
import { Unit } from '@openmeteo/sdk/unit';
import { fetchWeatherApi } from 'openmeteo';
@@ -241,7 +242,10 @@ export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams {
}
export interface ModelSeriesData {
modelName: string;
/** Stable identifier requested by the UI. */
modelId: string;
/** Concrete model identifier reported by the API (useful for seamless/best-match requests). */
resolvedModelId: string;
variables: Record<string, number[]>;
}
@@ -535,7 +539,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
* compatible with existing chart utilities.
*/
export async function fetchModelComparison(
params: ModelCompareParams
params: ModelCompareParams,
options: { signal?: AbortSignal } = {}
): Promise<ModelCompareResult> {
const forecastApiParams: Record<string, string | number | undefined> = {
latitude: String(params.latitude),
@@ -549,14 +554,28 @@ export async function fetchModelComparison(
timezone: params.timezone
};
const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
const responses = await fetchWeatherApi(
FORECAST_URL,
forecastApiParams,
undefined,
undefined,
undefined,
{ signal: options.signal }
);
if (responses.length === 0) throw new Error('The weather service returned no model data.');
if (responses.length !== params.models.length) {
throw new Error(
`The weather service returned ${responses.length} model responses for ${params.models.length} requested models.`
);
}
// With multiple models, we get one response per model
const firstResponse = responses[0];
const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
const timezone = firstResponse.timezone() ?? params.timezone ?? 'UTC';
const hourlyBlock = firstResponse.hourly()!;
const hourlyBlock = firstResponse.hourly();
if (!hourlyBlock) throw new Error('The weather service returned no hourly model data.');
const timestamps = getTimestamps(hourlyBlock);
// Extract sunrise/sunset from the first response's daily block
@@ -586,16 +605,22 @@ export async function fetchModelComparison(
);
hourlyFlat['time'] = timeInUnixSeconds;
for (const response of responses) {
for (const [responseIndex, response] of responses.entries()) {
const modelHourly = response.hourly();
if (!modelHourly) continue;
// Determine model name from the response
// The multi-model endpoint preserves request order and returns one response
// per requested model, including unavailable regional models. Keep that
// requested id as the stable UI key. The concrete id reported by Open-Meteo
// is metadata only: seamless and best-match requests may resolve to a
// different underlying domain.
const modelEnum = response.model();
const modelName = modelEnumToString(modelEnum);
const resolvedModelId = Model[modelEnum] ?? `model_${modelEnum}`;
const modelId = params.models[responseIndex] ?? resolvedModelId;
const modelData: ModelSeriesData = {
modelName,
modelId,
resolvedModelId,
variables: {}
};
@@ -608,7 +633,7 @@ export async function fetchModelComparison(
modelData.variables[varName] = values;
// Build flat key like "temperature_2m_icon_seamless"
const flatKey = `${varName}_${modelName}`;
const flatKey = `${varName}_${modelId}`;
hourlyFlat[flatKey] = values;
// Record unit
@@ -786,130 +811,6 @@ export async function fetchEnsembleForecast(
};
}
// ─── Model Enum Mapping ─────────────────────────────────────────────────────────
/**
* Maps the SDK Model enum integer to a string model name.
* This table must stay in sync with the @openmeteo/sdk Model enum.
*/
function modelEnumToString(modelEnum: number): string {
const modelMap: Record<number, string> = {
0: 'undefined',
1: 'best_match',
2: 'gfs_seamless',
3: 'gfs_global',
4: 'gfs_hrrr',
5: 'meteofrance_seamless',
6: 'meteofrance_arpege_seamless',
7: 'meteofrance_arpege_world',
8: 'meteofrance_arpege_europe',
9: 'meteofrance_arome_seamless',
10: 'meteofrance_arome_france',
11: 'meteofrance_arome_france_hd',
12: 'jma_seamless',
13: 'jma_msm',
14: 'jms_gsm',
15: 'jma_gsm',
16: 'gem_seamless',
17: 'gem_global',
18: 'gem_regional',
19: 'gem_hrdps_continental',
20: 'icon_seamless',
21: 'icon_global',
22: 'icon_eu',
23: 'icon_d2',
24: 'ecmwf_ifs04',
25: 'metno_nordic',
26: 'era5_seamless',
27: 'era5',
28: 'cerra',
29: 'era5_land',
30: 'ecmwf_ifs',
31: 'gwam',
32: 'ewam',
33: 'glofas_seamless_v3',
34: 'glofas_forecast_v3',
35: 'glofas_consolidated_v3',
36: 'glofas_seamless_v4',
37: 'glofas_forecast_v4',
38: 'glofas_consolidated_v4',
39: 'gfs025',
40: 'gfs05',
41: 'CMCC_CM2_VHR4',
42: 'FGOALS_f3_H_highresSST',
43: 'FGOALS_f3_H',
44: 'HiRAM_SIT_HR',
45: 'MRI_AGCM3_2_S',
46: 'EC_Earth3P_HR',
47: 'MPI_ESM1_2_XR',
48: 'NICAM16_8S',
49: 'cams_europe',
50: 'cams_global',
51: 'cfsv2',
52: 'era5_ocean',
53: 'cma_grapes_global',
54: 'bom_access_global',
55: 'bom_access_global_ensemble',
56: 'arpae_cosmo_seamless',
57: 'arpae_cosmo_2i',
58: 'arpae_cosmo_2i_ruc',
59: 'arpae_cosmo_5m',
60: 'ecmwf_ifs025',
61: 'ecmwf_aifs025',
62: 'gfs013',
63: 'gfs_graphcast025',
64: 'ecmwf_wam025',
65: 'meteofrance_wave',
66: 'meteofrance_currents',
67: 'ecmwf_wam025_ensemble',
68: 'ncep_gfswave025',
69: 'ncep_gefswave025',
70: 'knmi_seamless',
71: 'knmi_harmonie_arome_europe',
72: 'knmi_harmonie_arome_netherlands',
73: 'dmi_seamless',
74: 'dmi_harmonie_arome_europe',
75: 'metno_seamless',
76: 'era5_ensemble',
77: 'ecmwf_ifs_analysis',
78: 'ecmwf_ifs_long_window',
79: 'ecmwf_ifs_analysis_long_window',
80: 'ukmo_global_deterministic_10km',
81: 'ukmo_uk_deterministic_2km',
82: 'ukmo_seamless',
83: 'ncep_gfswave016',
84: 'ncep_nbm_conus',
85: 'ukmo_global_ensemble_20km',
86: 'ecmwf_aifs025_single',
87: 'jma_jaxa_himawari',
88: 'eumetsat_sarah3',
89: 'eumetsat_lsa_saf_msg',
90: 'eumetsat_lsa_saf_iodc',
91: 'satellite_radiation_seamless',
92: 'kma_gdps',
93: 'kma_ldps',
94: 'kma_seamless',
95: 'italia_meteo_arpae_icon_2i',
96: 'ukmo_uk_ensemble_2km',
97: 'meteofrance_arome_france_hd_15min',
98: 'meteofrance_arome_france_15min',
99: 'meteoswiss_icon_ch1',
100: 'meteoswiss_icon_ch2',
101: 'meteoswiss_icon_ch1_ensemble',
102: 'meteoswiss_icon_ch2_ensemble',
103: 'meteoswiss_icon_seamless',
104: 'ncep_nam_conus',
105: 'icon_d2_ruc',
106: 'ecmwf_seas5',
107: 'ecmwf_ec46',
108: 'ecmwf_seasonal_seamless',
109: 'ecmwf_ifs_seamless',
110: 'jma_jaxa_mtg_fci',
111: 'gem_hrdps_west'
};
return modelMap[modelEnum] ?? `model_${modelEnum}`;
}
// ─── Historical (Archive) Types ─────────────────────────────────────────────
export interface HistoricalDailyData {
+22
View File
@@ -10,6 +10,18 @@ import { writable } from 'svelte/store';
*/
export const pageContentReady = writable(true);
/**
* Raised by the layout just before it captures a navigation away from the maps
* page. The map is a cross-origin iframe, which browsers do not paint into a
* view transition snapshot - captured bare, the outgoing page would carry a
* hole where the map was. The maps page answers by laying an opaque
* same-origin cover over the iframe (the same one that hides the map while it
* boots), so the snapshot shows a clean panel and the cross-fade has something
* real to fade from. The layout lowers it again once the navigation is
* through.
*/
export const mapTransitionCover = writable(false);
/**
* Called by the layout before it swaps to a route that fetches its own data.
*
@@ -22,6 +34,16 @@ export function markPageLoading(): void {
pageContentReady.set(false);
}
/**
* The counterpart, for a route that has nothing to wait for (the maps page, the
* legal pages). Without it a navigation away from a page that never resolved
* would leave the flag stuck on "loading", and the layout's overlay would sit on
* top of a page that is perfectly finished.
*/
export function markPageReady(): void {
pageContentReady.set(true);
}
/**
* Declare a page's readiness. Pass a getter for "my data has arrived". Only
* ever sets the flag - clearing it is the layout's job (see above).
@@ -5,7 +5,7 @@
import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { isSupporter, refreshSupporter } from './supporter';
import { isSupporter, refreshSupporter } from './store';
let open = $state(false);
@@ -6,7 +6,7 @@
import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
import { isSupporter, refreshSupporter, supporterState } from './supporter';
import { isSupporter, refreshSupporter, supporterState } from './store';
interface Props {
/** Short feature name shown in the locked panel headline. */
@@ -2,7 +2,7 @@
/**
* The supporter mark. A heart rather than a padlock: nothing here is locked
* away as a punishment - the extras are a thank-you for chipping in, and a
* padlock framed it as a paywall. Filled once someone is supporting,
* padlock framed it as a hard wall. Filled once someone is supporting,
* outlined as an invitation before that.
*/
interface Props {
@@ -9,13 +9,7 @@
import * as m from '$lib/paraglide/messages';
import { SIGNUP_URL, getSupporterPrice } from './config';
import {
clearLicense,
isSupporter,
storedLicenseKey,
supporterState,
verifyKey
} from './supporter';
import { clearLicense, isSupporter, storedLicenseKey, supporterState, verifyKey } from './store';
interface Props {
open?: boolean;
@@ -1,29 +1,40 @@
import * as m from '$lib/paraglide/messages';
/**
* Paywall configuration.
* Supporter configuration.
*
* The frontend stays fully static and open source; the only server piece is the
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
* build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a
* `.env` file), otherwise the sensible drizz.li defaults are used.
* build at your deployment with the `VITE_SUPPORTER_*` env vars (e.g. in a
* `.env` file), otherwise the sensible drizz.li defaults are used. The
* pre-rename `VITE_PAYWALL_*` names still work.
*/
const env = import.meta.env as Record<string, string | undefined>;
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
/**
* Master switch for the supporter features. While this is `false` everything
* supporter-related is parked: the nav entry points are hidden, no key is
* verified, and every gated page renders as if the visitor were a supporter.
* The logic stays in place set `VITE_SUPPORTER_ENABLED=true` (or default this
* to `true`) to bring it back.
*/
export const SUPPORTER_ENABLED = env.VITE_SUPPORTER_ENABLED === 'true';
/** Base URL of the self-hosted verify API (drizzli-paywall). */
export const PAYWALL_API_BASE = stripTrailingSlash(
env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
export const SUPPORTER_API_BASE = stripTrailingSlash(
env.VITE_SUPPORTER_API_BASE ?? env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
);
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
/** Where prospective supporters go to sign up (the verify API's signup form). */
export const SIGNUP_URL =
env.VITE_SUPPORTER_SIGNUP_URL ?? env.VITE_PAYWALL_SIGNUP_URL ?? `${SUPPORTER_API_BASE}/`;
// ─── Location-based pricing ──────────────────────────────────────────────────
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
// from their location. Mirror that here so the paywall copy matches. Detection
// from their location. Mirror that here so the supporter copy matches. Detection
// is timezone/locale based (no network geo lookup) and guarded for SSR.
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
@@ -54,7 +65,7 @@ export function detectCurrency(): SupporterCurrency {
return 'EUR';
}
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / Monat". */
/** Display price for the locked panel, e.g. "€3 / month" or "CHF 3 / Monat". */
export function getSupporterPrice(): string {
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
// deployment's .env keeps working.
@@ -8,7 +8,7 @@
*
* This gate is a convenience/honor-system gate: the frontend is open source and
* static, so it can be bypassed. Keeping the subscriber list server-side (in the
* paywall repo) is what makes it meaningful in practice.
* verify API's repo) is what makes it meaningful in practice.
*/
import { derived, get, writable } from 'svelte/store';
@@ -16,7 +16,7 @@ import { persisted } from 'svelte-persisted-store';
import * as m from '$lib/paraglide/messages';
import { PAYWALL_API_BASE } from './config';
import { SUPPORTER_API_BASE, SUPPORTER_ENABLED } from './config';
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
export const storedLicenseKey = persisted<string>('license_key', '');
@@ -75,10 +75,14 @@ function notExpired(expires: string | null | undefined): boolean {
* Whether supporter content should be shown. A live "valid"/"invalid" result
* wins; otherwise we fall back to the cached result (so a reload or a brief
* network blip doesn't lock a paying user out).
*
* While supporter features are parked (`SUPPORTER_ENABLED === false`) this is always
* true, so every gated page renders unlocked for everyone.
*/
export const isSupporter = derived(
[supporterState, storedSupporterCache],
([$state, $cache]): boolean => {
if (!SUPPORTER_ENABLED) return true;
if ($state.status === 'valid') return true;
if ($state.status === 'invalid') return false;
return !!($cache && $cache.valid && notExpired($cache.expires));
@@ -98,6 +102,10 @@ export interface VerifyResult {
* subscription can still show a "renew" state) but the cache is marked invalid.
*/
export async function verifyKey(key: string): Promise<VerifyResult> {
// Parked: never touch the verify API (and report success, since everything
// is unlocked anyway).
if (!SUPPORTER_ENABLED) return { valid: true, expires: null };
const trimmed = key.trim();
if (!trimmed) {
supporterState.set({ status: 'invalid' });
@@ -106,7 +114,7 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
supporterState.set({ status: 'checking' });
try {
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
const res = await fetch(`${SUPPORTER_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
headers: { accept: 'application/json' }
});
const data = (await res.json()) as VerifyResult;
@@ -135,6 +143,8 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
export async function refreshSupporter(): Promise<void> {
if (!SUPPORTER_ENABLED) return;
const key = get(storedLicenseKey);
if (!key) {
supporterState.set({ status: 'idle' });
+44 -10
View File
@@ -11,12 +11,40 @@ import type { Locale as DateFnsLocale } from 'date-fns';
// locale the messages do.
const DATE_LOCALES: Record<string, DateFnsLocale> = { en: enGB, de, es, fr, it };
/**
* Normalises anything date-shaped into a plain `Date`, or null when it does not
* describe a real instant.
*
* Two reasons every helper below starts here:
*
* 1. date-fns copies its input with `new date.constructor(value)`. Hand it the
* reactive `SvelteDate` the pages use for the selected day and it builds
* *another* SvelteDate, then reads the copy's fields back through memoised
* signals - a signal graph per formatted timestamp, in a path that runs
* hundreds of times per render.
* 2. date-fns throws `RangeError: Invalid time value` on an invalid date. Thrown
* from inside a render (or inside a view-transition callback, where it
* surfaces as an unhandled rejection) that takes the whole page down for what
* is really one unformattable cell.
*/
function plainDate(date: Date | null | undefined): Date | null {
const time = date?.getTime?.();
if (time == null || !Number.isFinite(time)) return null;
// already a plain Date: no copy needed
return date!.constructor === Date ? (date as Date) : new Date(time);
}
/**
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
*
* Returns '' for a date or zone it cannot format - callers compare these strings
* or print them, and both degrade gracefully on an empty one.
*/
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
return formatInTimeZone(date, timeZone, pattern, {
const d = plainDate(date);
if (!d || !timeZone) return '';
return formatInTimeZone(d, timeZone, pattern, {
locale: DATE_LOCALES[getLocale()] ?? enGB
});
}
@@ -26,16 +54,20 @@ export function formatZoned(date: Date, timeZone: string, pattern: string): stri
* Important for comparing weather forecast days against a selected date.
*/
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
const z1 = toZonedTime(date1, timeZone);
const z2 = toZonedTime(date2, timeZone);
return isSameDayDateFns(z1, z2);
const d1 = plainDate(date1);
const d2 = plainDate(date2);
if (!d1 || !d2 || !timeZone) return false;
return isSameDayDateFns(toZonedTime(d1, timeZone), toZonedTime(d2, timeZone));
}
/**
* Gets the numeric hour (0-23) for a date in a specific timezone.
* Gets the numeric hour (0-23) for a date in a specific timezone, or NaN when
* the date cannot be read.
*/
export function getZonedHour(date: Date, timeZone: string): number {
return parseInt(formatInTimeZone(date, timeZone, 'H'), 10);
const d = plainDate(date);
if (!d || !timeZone) return NaN;
return parseInt(formatInTimeZone(d, timeZone, 'H'), 10);
}
/**
@@ -43,9 +75,11 @@ export function getZonedHour(date: Date, timeZone: string): number {
* or a formatted date string, all relative to the target timezone.
*/
export function getRelativeDayLabel(date: Date, timeZone: string): string {
const now = new Date();
const zonedDate = toZonedTime(date, timeZone);
const zonedNow = toZonedTime(now, timeZone);
const d = plainDate(date);
if (!d || !timeZone) return '';
const zonedDate = toZonedTime(d, timeZone);
const zonedNow = toZonedTime(new Date(), timeZone);
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today();
@@ -57,7 +91,7 @@ export function getRelativeDayLabel(date: Date, timeZone: string): string {
yesterday.setDate(yesterday.getDate() - 1);
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
return formatZoned(date, timeZone, 'EEE d MMM');
return formatZoned(d, timeZone, 'EEE d MMM');
}
/**
+49 -13
View File
@@ -1,7 +1,12 @@
import { tick } from 'svelte';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
import {
canStartViewTransition,
prefersReducedMotion,
skipActiveViewTransition,
startViewTransition,
supportsViewTransitions
} from './view-transition';
/**
* Runs a day change inside a view transition, so the outgoing day is still on
@@ -12,26 +17,57 @@ const prefersReducedMotion = () =>
* `.day-region-*` in routes/layout.css); everything else - the strip, the
* header, the page chrome - is pinned by the `day-switch` class so it stays
* completely still.
*
* A day switch that lands while a navigation transition is still on screen just
* applies: starting a rival transition would skip the running one and flash the
* page (see view-transition.ts).
*/
/** Guards the shared cleanup below against a switch superseding a switch. */
let dayTransitionToken = 0;
export async function runDayTransition(update: () => void): Promise<void> {
if (prefersReducedMotion() || !document.startViewTransition) {
if (!canStartViewTransition()) {
update();
return;
}
const token = ++dayTransitionToken;
const root = document.documentElement;
root.classList.add('day-switch');
// The region snapshots include the part of the table normally scrolled up
// behind the sticky strip; the transition overlay is clipped at the bar's
// bottom edge so they cannot paint over the (live, clickable) strip.
// Measured per switch because the bar's height follows the scroll collapse.
const bar = document.querySelector('.daystrip .strip-row');
if (bar) {
const clip = Math.max(0, bar.getBoundingClientRect().bottom);
root.style.setProperty('--day-switch-clip', `${clip}px`);
}
// The page stays scrollable during the fade, but the snapshots and the clip
// line above are anchored to where things were at capture - so the first
// sign of scrolling finishes the fade on the spot instead of animating
// against a moving page.
const skip = () => skipActiveViewTransition();
window.addEventListener('wheel', skip, { passive: true });
window.addEventListener('touchmove', skip, { passive: true });
try {
// Svelte applies the change on the next tick; the transition has to wait
// for that before it snapshots the new state.
await document.startViewTransition(async () => {
update();
await tick();
}).finished;
} catch {
/* a superseded transition is fine - the DOM is already up to date */
// for that before it snapshots the new state. `day-switch` scopes which
// regions take part (see routes/layout.css) and is cleared when it ends.
await startViewTransition(
async () => {
update();
await tick();
},
{ rootClass: 'day-switch' }
);
} finally {
root.classList.remove('day-switch');
window.removeEventListener('wheel', skip);
window.removeEventListener('touchmove', skip);
// a newer switch owns the clip var now; only the last one may clear it
if (token === dayTransitionToken) root.style.removeProperty('--day-switch-clip');
}
}
@@ -45,7 +81,7 @@ export function daySwap(node: HTMLElement, key: unknown) {
const play = () => {
// view transitions handle it properly where they exist
if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return;
if (supportsViewTransitions() || prefersReducedMotion()) return;
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
duration: 460,
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
+24
View File
@@ -76,6 +76,16 @@ interface ResolveLocationOptions {
};
}
/**
* Geocoding results for a route segment, kept for the life of the process.
*
* A city's coordinates do not change, and the same segment is resolved over and
* over: once per per-location route during the prerender (five builds of the
* same lookup for every city), and again on every client-side hop from a city's
* week page to its comparison or archive.
*/
const resolvedLocations = new Map<string, GeoLocation>();
export async function resolveLocationFromRoute({
urlLocation,
routePrefix,
@@ -86,6 +96,11 @@ export async function resolveLocationFromRoute({
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
}
// The canonical-path check below still has to run per call (the same city is
// reached under different route prefixes), so only the lookup is cached.
const cached = resolvedLocations.get(urlLocation);
if (cached) return finishResolve(cached, routePrefix, event);
let urlLocationName: string;
let urlLocationId: string | undefined;
@@ -124,6 +139,15 @@ export async function resolveLocationFromRoute({
location = candidate;
}
resolvedLocations.set(urlLocation, location);
return finishResolve(location, routePrefix, event);
}
function finishResolve(
location: GeoLocation,
routePrefix: string,
event: ResolveLocationOptions['event']
): GeoLocation {
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
// every path with a trailing slash. Match that here or the equality check
// never holds and the redirect loops forever. The comparison also has to
+3 -4
View File
@@ -9,7 +9,7 @@
* automatically once the maps app ships seamless support; until then they fall
* back to their widest-coverage member (a regional member could be entirely
* off-screen). Models without an entry are tried under their own id; models
* the map does not serve at all (best_match, bom, google, UKMO ensembles)
* the map does not serve at all (best_match, google, UKMO ensembles)
* resolve to null.
*/
const modelDomainCandidates: Record<string, string[]> = {
@@ -46,9 +46,8 @@ const modelDomainCandidates: Record<string, string[]> = {
// MeteoSwiss (CH2 covers a wider area than CH1)
meteoswiss_icon_seamless: ['meteoswiss_icon_ch2'],
// KMA Korea (kma_ldps is not served by the maps project)
kma_seamless: ['kma_gdps'],
kma_ldps: ['kma_gdps'],
// CHMI Czech Republic (Central Europe is the widest native domain)
chmi_aladin_seamless: ['chmi_aladin_seamless', 'chmi_aladin_central_europe_2km'],
// JMA Japan
jma_seamless: ['jma_seamless', 'jma_gsm'],
+44
View File
@@ -0,0 +1,44 @@
import type { TransitionConfig } from 'svelte/transition';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* Fades a loading placeholder out *over* the content that replaces it.
*
* A plain `out:fade` is not usable on a skeleton: Svelte keeps the node in the
* layout for the length of the transition, so the real content stacks below the
* placeholder and the whole page jumps up the moment the placeholder finally
* unmounts. That is why these placeholders only ever faded in - the swap was
* abrupt, but at least nothing moved.
*
* This pins the placeholder to the box it already occupies and takes it out of
* flow for the fade instead. The content settles into its final position
* immediately and the skeleton dissolves on top of it.
*
* Requirements at the call site: the placeholder's parent must be positioned
* (`class="relative"`) and must be the same element that renders the real
* content, or the overlay lands somewhere else on the page.
*/
export function skeletonOut(node: HTMLElement, { duration = 260 } = {}): TransitionConfig {
if (prefersReducedMotion()) return { duration: 0 };
// measured while the node is still in flow, which is when Svelte builds the
// transition - the values below are what freeze it in place
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node;
return {
duration,
css: (t) => `
opacity: ${t};
position: absolute;
top: ${offsetTop}px;
left: ${offsetLeft}px;
width: ${offsetWidth}px;
height: ${offsetHeight}px;
margin: 0;
pointer-events: none;
z-index: 5;
`
};
}
+40 -7
View File
@@ -7,22 +7,31 @@
* history entry or re-runs a load - the back button still means "the page
* before", not "the previous day I clicked".
*
* Callers must pass the URL *untracked* (`get(page).url`, not `$page.url`).
* Reading it reactively inside the same effect that writes it creates a loop:
* replaceState publishes a new URL, the effect re-runs, writes again - which
* Svelte eventually kills with `effect_update_depth_exceeded`, hanging the page.
* The base is `location`, deliberately not `page.url`. Shallow routing does not
* republish the URL: `replaceState` writes the history entry (and files the
* *previous* `page.url` in it, so a popstate can restore it) but leaves
* `page.url` on the last navigated URL. Diffing against that stale value is
* wrong in exactly one direction - clearing a parameter. Opening a day writes
* `?day=`, `page.url` still has none, so asking to remove it produces a URL
* identical to the stale one, the write is skipped as a no-op, and the
* parameter stays in the address bar for good.
*
* Reading `location` rather than a passed-in URL also removes the old trap that
* callers had to pass it untracked: an effect that both read `$page.url` and
* wrote to it looped until `effect_update_depth_exceeded` hung the page.
*/
import { browser } from '$app/environment';
import { replaceState } from '$app/navigation';
export function syncSearchParams(url: URL, updates: Record<string, string | null>): void {
export function syncSearchParams(updates: Record<string, string | null>): void {
if (!browser) return;
const next = new URL(url);
const current = new URL(window.location.href);
const next = new URL(current);
for (const [key, value] of Object.entries(updates)) {
if (value == null || value === '') next.searchParams.delete(key);
else next.searchParams.set(key, value);
}
if (next.href === url.href) return;
if (next.href === current.href) return;
try {
replaceState(next, {});
} catch {
@@ -39,6 +48,30 @@ export function syncSearchParams(url: URL, updates: Record<string, string | null
}
}
/**
* The value to mirror, or null when it is the view's default. Defaults belong
* in the code, not the query string: a shared link should carry only what the
* visitor actually changed, and going back to the default has to clear the
* parameter again rather than pin the default in place.
*/
export function unlessDefault(value: string | null | undefined, fallback: string): string | null {
return value && value !== fallback ? value : null;
}
/**
* Same, for the comma-separated list parameters. Order counts - the models are
* plotted, coloured and legended in the order they are listed, so a reordered
* line-up is a different view even when it holds the same entries.
*/
export function listUnlessDefault(
values: string[] | null | undefined,
fallback: string[]
): string | null {
if (!values?.length) return null;
const joined = values.join(',');
return joined === fallback.join(',') ? null : joined;
}
/** Reads a comma-separated list, dropping empties. */
export function readList(url: URL, key: string): string[] | null {
const raw = url.searchParams.get(key);
+142
View File
@@ -0,0 +1,142 @@
/**
* Central entry point for every view transition in the app.
*
* Two things it exists to get right.
*
* **One transition at a time, but only where it matters.** Starting a
* transition while another is running makes the browser skip the first, and
* what that looks like depends entirely on which phase the first is in:
*
* - *capturing* - its update callback has not resolved yet, so the screen is
* frozen on the outgoing snapshot. Skipping drops that snapshot on the spot
* and pops the half-updated live DOM into view: the whole-screen flash.
* This phase is common here, because navigation deliberately holds the
* callback open while the new page fetches, and `/` plus every bare
* `/weather/<view>/` page is a redirect stub that navigates again from
* `onMount` - one click, two or three navigations.
* - *animating* - the DOM is already in its final state and the pseudo
* elements are playing out. Skipping just finishes them early, landing on
* exactly the state they were heading for.
*
* So a new transition rides along inside the running one only while it is
* capturing; once it is animating, superseding it is the better answer (waiting
* would strand the new update under a stale snapshot until the animation ends).
*
* **A scoping class.** `rootClass` is set on `<html>` for the life of the
* transition, so the stylesheet can tell a page swap from a day switch and pin
* the parts that are identical on both sides (see routes/layout.css).
*/
type UpdateCallback = () => void | Promise<void>;
interface Options {
/** Class set on `<html>` while the transition runs, for scoping CSS. */
rootClass?: string;
/**
* Set false to run the update without a transition. For content the browser
* does not paint into a snapshot - a cross-origin iframe - where animating
* means animating a hole rather than a cross-fade.
*/
enabled?: boolean;
}
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
let capturing: Promise<void> | null = null;
/**
* How many running transitions hold each scoping class. A superseded
* transition's cleanup fires while its successor is mid-capture (skipping
* rejects `finished` on a microtask, which runs before the next render);
* without the count it would strip the class out from under the successor,
* and a day switch captured without `day-switch` falls back to the full-page
* fade it exists to prevent.
*/
const rootClassHolds = new Map<string, number>();
/** The most recently started transition, while it is capturing or animating. */
let active: ViewTransition | null = null;
/**
* Finishes the running transition's animation on the spot (the DOM is already
* in its final state, so this is always safe). Used to hand the screen back
* the moment the user starts scrolling under a day switch, rather than
* animating against a moving target.
*/
export function skipActiveViewTransition(): void {
active?.skipTransition();
}
export const supportsViewTransitions = (): boolean =>
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
export const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* True while a transition is frozen on its outgoing snapshot - the phase in
* which starting a rival transition would flash the page.
*/
export const isViewTransitionCapturing = (): boolean => capturing !== null;
/** Whether `startViewTransition` would actually open one right now. */
export const canStartViewTransition = (): boolean =>
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionCapturing();
/**
* Runs `update` inside a view transition, or straight away when one cannot (or
* must not) be started. Resolves once the transition has finished animating -
* or as soon as the update is done, when it ran on its own.
*/
export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
const { rootClass, enabled = true } = options;
if (!enabled || !canStartViewTransition()) {
return Promise.resolve(update()).then(
() => {},
(error: unknown) => {
console.error('view transition update failed', error);
}
);
}
const root = document.documentElement;
if (rootClass) {
rootClassHolds.set(rootClass, (rootClassHolds.get(rootClass) ?? 0) + 1);
root.classList.add(rootClass);
}
const transition = document.startViewTransition(update);
active = transition;
// A throw inside the callback rejects `updateCallbackDone` as well as
// `finished`. Nothing awaits the former, and an unhandled rejection there is
// what turns one bad render into an "Uncaught" error on the page.
const captured = transition.updateCallbackDone.then(
() => {},
(error: unknown) => {
console.error('view transition update failed', error);
}
);
capturing = captured;
void captured.then(() => {
if (capturing === captured) capturing = null;
});
// A superseded transition rejects `finished`; the DOM is already up to date,
// so that is not an error worth surfacing.
return transition.finished
.then(
() => {},
() => {}
)
.finally(() => {
if (active === transition) active = null;
if (!rootClass) return;
const holds = (rootClassHolds.get(rootClass) ?? 1) - 1;
if (holds > 0) {
rootClassHolds.set(rootClass, holds);
} else {
rootClassHolds.delete(rootClass);
root.classList.remove(rootClass);
}
});
}
+226 -61
View File
@@ -6,16 +6,26 @@
import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
import {
mapTransitionCover,
markPageLoading,
markPageReady,
pageContentReady
} from '$lib/stores/page-transition.svelte';
import { storedTheme } from '$lib/stores/settings';
import {
canStartViewTransition,
startViewTransition,
supportsViewTransitions
} from '$lib/utils/view-transition';
import Footer from '$lib/components/navigation/footer.svelte';
import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
import favicon from '$lib/assets/favicon.svg';
import { routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import './layout.css';
@@ -31,7 +41,9 @@
const apply = () => {
const root = document.documentElement;
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
const paint = () => root.classList.toggle('dark', dark);
const paint = () => {
root.classList.toggle('dark', dark);
};
// The very first application is just painting the stored theme - only
// an actual switch afterwards is worth cross-fading.
@@ -42,9 +54,17 @@
return;
}
if (document.startViewTransition) {
if (canStartViewTransition() && !onMapsPage()) {
// one cross-fade of the whole document; component transitions untouched
document.startViewTransition(paint);
void startViewTransition(paint);
return;
}
// A navigation transition already owns the screen: repaint under it
// rather than skipping it (which would flash), and fall back to the
// colour transition below when the browser has none at all.
if (supportsViewTransitions()) {
paint();
return;
}
@@ -63,25 +83,91 @@
// ── Page cross-fade ───────────────────────────────────────────────────────
// A real cross-fade needs the outgoing page still on screen while the
// incoming one appears - so the view transition is opened at navigation and
// deliberately held open until the new page reports that its data has landed.
// The browser keeps showing the old snapshot for that whole time (rather than
// flashing a skeleton), then fades it into the finished page.
// held open for a short grace period, waiting for the new page to report that
// its data has landed. A cached or fast response lands inside that window and
// the old page fades straight into the finished one, no skeleton in between.
//
// The wait is capped: past the ceiling we cross-fade into whatever is on
// screen instead of freezing the UI on a slow network.
const READY_CEILING_MS = 2200;
const FADE_OUT_MS = 170;
// Past the grace period the wait gives up and the loading overlay takes over:
// holding the old page frozen any longer looks like a dead click, and the
// overlay is the honest answer - something is happening, it just isn't here
// yet. Note the order: the overlay has to be in the DOM *before* the
// transition captures the incoming state, because a running view transition
// freezes the page and nothing painted after that point can appear.
//
// There is deliberately no fade for browsers without view transitions. The
// old fallback dimmed the outgoing page to nothing and brought the new one
// back up, which on a single layer is a flash of the bare background rather
// than a cross-fade. Swapping outright and letting the overlay carry the
// "loading" message is quieter and honest.
const READY_HOLD_MS = 350;
// Nothing reports ready when a fetch fails outright, so the overlay needs its
// own way out rather than sitting on top of an error message forever.
const OVERLAY_CEILING_MS = 15000;
let contentVisible = $state(true);
let revealTimer = 0;
// The map is a cross-origin iframe, and a browser does not paint one into a
// view transition snapshot - captured bare, any transition with the maps page
// on either side would animate a hole where the map is. The way out is to
// make sure the map is never what gets captured: the maps page keeps an
// opaque cover over the iframe while the map boots (so an arrival fades into
// a clean panel, and the map fades up once ready), and raises the same cover
// again just before a departure is captured (see mapTransitionCover). Both
// snapshots then hold real pixels and the maps page transitions like any
// other route.
const MAPS_ROUTE = '/weather/maps';
const onMapsPage = () => routePath(get(page).url.pathname).startsWith(MAPS_ROUTE);
const reducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let loadingOverlay = $state(false);
let overlayCeilingTimer = 0;
// A view transition freezes the page, so a Svelte in-transition started under
// one cannot play - the overlay would be captured at opacity 0 and pop in
// afterwards. Inside a transition the cross-fade does the fading instead.
let overlayFadesIn = $state(true);
// Routes that fetch their own forecast after mounting. Knowing this up front
// is what makes the wait reliable: the layout clears the ready flag before
// the swap rather than trusting the incoming page to have done it.
const DATA_ROUTES = new Set([
function showOverlay(animate: boolean): void {
overlayFadesIn = animate;
loadingOverlay = true;
clearTimeout(overlayCeilingTimer);
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
}
function hideOverlay(): void {
clearTimeout(overlayCeilingTimer);
loadingOverlay = false;
}
/**
* Manual dismissal. The overlay reports on a fetch it does not control, so
* "stuck" is always a possibility (a page that never reports ready, a request
* that neither resolves nor rejects) - and the page behind it still works.
* Whatever was loading carries on; only the veil goes.
*/
function dismissOverlay(): void {
hideOverlay();
}
function onWindowKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && loadingOverlay) dismissOverlay();
}
/** The page that just mounted has its data: whatever we were waiting for is in. */
$effect(() => {
if ($pageContentReady) hideOverlay();
});
// Routes that are not finished on arrival: the five that fetch a forecast
// after mounting, the maps page (whose map is a cross-origin iframe that has
// to load first), and the redirect stubs, which render nothing at all and
// bounce to a located URL from `onMount`. Knowing this up front is what makes
// the wait reliable - the layout clears the ready flag before the swap rather
// than trusting the incoming page to have done it.
const PENDING_ROUTES = new Set([
'/',
'/weather/week',
'/weather/14-day',
'/weather/compare',
'/weather/seasonal',
'/weather/historical',
'/weather/maps',
'/weather/week/[location]',
'/weather/14-day/[location]',
'/weather/compare/[location]',
@@ -89,25 +175,67 @@
'/weather/historical/[location]'
]);
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
async function waitForContent(): Promise<void> {
const deadline = Date.now() + READY_CEILING_MS;
/**
* Resolves once the freshly mounted page has its data, or once the grace
* period is up - in which case the overlay goes up first, so it is part of
* the state the transition is about to snapshot.
*/
async function waitForContent(underTransition: boolean): Promise<void> {
const deadline = Date.now() + READY_HOLD_MS;
while (!get(pageContentReady) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 40));
await new Promise((resolve) => setTimeout(resolve, 20));
}
if (!get(pageContentReady)) showOverlay(!underTransition);
// one more frame so the page paints its data before the snapshot is taken
await tick();
}
onNavigate((navigation) => {
if (reducedMotion()) return;
/**
* True when the navigation lands on the route and params the page is already
* showing - the sidebar's home link from the week page it points at, or a
* link that differs only in the query string.
*
* SvelteKit keeps the page component mounted for those, and nothing it holds
* changes: its forecast is already fetched, so the readiness effect it
* registered never re-runs and never re-announces. Clearing the flag for such
* a navigation strands it cleared, and the overlay sits there until its
* ceiling. There is genuinely nothing to wait for, so don't clear it.
*/
function landsOnCurrentPage(navigation: {
from: { route: { id: string | null }; params: Record<string, string> | null } | null;
to: { route: { id: string | null }; params: Record<string, string> | null } | null;
}): boolean {
const from = navigation.from;
const to = navigation.to;
if (!from?.route.id || from.route.id !== to?.route.id) return false;
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
}
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
if (loadsData) markPageLoading();
onNavigate(async (navigation) => {
const pending =
PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
// Either way the flag is set explicitly: leaving a page that never resolved
// for one that has nothing to load would otherwise strand the overlay.
if (pending) markPageLoading();
else markPageReady();
if (typeof document.startViewTransition === 'function') {
return new Promise<void>((swap) => {
document.startViewTransition(async () => {
// `startViewTransition` decides whether a transition is possible at all
// (support, reduced motion, one already capturing) and runs the update
// inline when it is not - so there is exactly one path from here down.
const underTransition = canStartViewTransition();
// Leaving the maps page: cover the iframe before the outgoing state is
// captured, so the snapshot holds a clean panel instead of a hole where
// the map was. The tick makes sure the cover is actually in the DOM by
// the time the capture reads it.
if (underTransition && navigation.from?.route?.id === MAPS_ROUTE) {
mapTransitionCover.set(true);
await tick();
}
return new Promise<void>((swap) => {
void startViewTransition(
async () => {
// hand control back so SvelteKit swaps the DOM underneath the
// frozen snapshot of the old page
swap();
@@ -115,40 +243,28 @@
// rejects this promise; that is not an error worth surfacing,
// and leaving it unhandled shows up as "navigation aborted".
await navigation.complete.catch(() => {});
if (loadsData) await waitForContent();
});
});
}
// No view transitions: fall back to fading out, then back in on arrival.
clearTimeout(revealTimer);
contentVisible = false;
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
if (pending) await waitForContent(underTransition);
},
// pins the chrome that is the same on both sides (routes/layout.css)
{ rootClass: 'page-switch', enabled: underTransition }
);
});
});
let mainEl = $state<HTMLElement | null>(null);
afterNavigate((navigation) => {
// The departure snapshot (if any) is taken by now, so the maps cover has
// done its job; lowering it here also means a later visit to the maps page
// starts from its own boot cover rather than a stuck one.
mapTransitionCover.set(false);
// The page scrolls inside <main>, not the window, so SvelteKit's own scroll
// handling never touches it and a new page would open half way down.
// Back/forward and in-page anchors keep their position.
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
mainEl?.scrollTo({ top: 0 });
}
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
contentVisible = true;
return;
}
const startedAt = Date.now();
const reveal = () => {
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
contentVisible = true;
return;
}
revealTimer = window.setTimeout(reveal, 60);
};
reveal();
});
// the maps page embeds a full-bleed map: no padding, no scrolling
@@ -171,14 +287,66 @@
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<!-- the icon itself lives in app.html, so the SPA fallback carries it too -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
indicator, not a modal, so the nav and the search stay usable while a slow
forecast is still on its way. It is also always dismissable - Escape or the
close button - because a veil nobody can get rid of is worse than no veil,
and the page underneath is perfectly usable either way. -->
<svelte:window onkeydown={onWindowKeydown} />
{#if loadingOverlay}
<div
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
in:fade={{ duration: overlayFadesIn ? 120 : 0 }}
out:fade={{ duration: 280 }}
role="status"
aria-live="polite"
>
<button
type="button"
class="pointer-events-auto absolute top-3 right-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:bg-muted hover:text-foreground md:top-4 md:right-4"
onclick={dismissOverlay}
aria-label={m.page_loading_dismiss()}
title={m.page_loading_dismiss()}
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<div
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
>
<svg
class="h-4 w-4 animate-spin text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="text-sm font-semibold">{m.page_loading()}</span>
</div>
</div>
{/if}
<div class="flex h-screen overflow-hidden bg-background text-foreground">
<!-- Desktop sidebar -->
<div class="hidden h-full shrink-0 md:block">
<div class="sidebar-region hidden h-full shrink-0 md:block">
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
</div>
@@ -221,10 +389,7 @@
{:else}
<!-- cap the content width on very large screens; the footer below
gives the page its ending, so only modest bottom room is needed -->
<div
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
class:page-fade-hidden={!contentVisible}
>
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
{@render children()}
</div>
<!-- full-bleed footer inside the scroll area (its own inner max-w),
+2 -1
View File
@@ -29,7 +29,8 @@
Drizz.li ist kostenlos nutzbar. Wenn es Ihnen nützt, können Sie es mit einem kleinen monatlichen
Beitrag unterstützen (ab 3 € / Monat) - das hilft, Domain, Hosting und Entwicklungszeit zu
decken. Als Dankeschön schalten Unterstützerinnen und Unterstützer die Extras frei: historisches
Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie erscheinen.
Wetter mit Vergleich zu den Klimanormalen, die saisonalen Aussichten für die kommenden Monate
sowie neue Funktionen, sobald sie erscheinen.
</p>
<p>
Beiträge laufen über eine einfache Banküberweisung - kein Zahlungsdienstleister, keine
+2 -2
View File
@@ -25,8 +25,8 @@
<p>
Drizz.li is free to use. If you find it useful, you can support it with a small monthly
contribution (from €3 / month) - it helps cover the domain, hosting and development time. As a
thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, and
new supporter features as they land.
thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, the
seasonal outlook for the months ahead, and new supporter features as they land.
</p>
<p>
Contributions are handled by a simple bank transfer - no payment processor, no stored card
+2 -1
View File
@@ -29,7 +29,8 @@
Drizz.li es gratuito. Si te resulta útil, puedes apoyarlo con una pequeña aportación mensual
(desde 3 € al mes): ayuda a cubrir el dominio, el alojamiento y el tiempo de desarrollo. Como
agradecimiento, quienes colaboran desbloquean los extras: clima histórico con comparación frente
a las normales climáticas y las nuevas funciones que vayan llegando.
a las normales climáticas, la perspectiva estacional de los próximos meses y las nuevas
funciones que vayan llegando.
</p>
<p>
Las aportaciones se gestionan mediante una simple transferencia bancaria: sin pasarela de pago y
+2 -1
View File
@@ -31,7 +31,8 @@
Drizz.li est gratuit. S'il vous est utile, vous pouvez le soutenir par une petite contribution
mensuelle (à partir de 3 € par mois) : elle aide à couvrir le nom de domaine, l'hébergement et
le temps de développement. En remerciement, les contributeurs débloquent les bonus : la météo
historique avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir.
historique avec comparaison aux normales climatiques, l'aperçu saisonnier des mois à venir,
ainsi que les nouvelles fonctions à venir.
</p>
<p>
Les contributions passent par un simple virement bancaire : aucun prestataire de paiement,
+2 -1
View File
@@ -29,7 +29,8 @@
Drizz.li è gratuito. Se lo trovi utile, puoi sostenerlo con un piccolo contributo mensile (da 3
€ al mese): aiuta a coprire dominio, hosting e tempo di sviluppo. Come ringraziamento, chi
sostiene il progetto sblocca gli extra: meteo storico con confronto rispetto alle normali
climatiche e le nuove funzioni man mano che arrivano.
climatiche, le prospettive stagionali per i mesi a venire e le nuove funzioni man mano che
arrivano.
</p>
<p>
I contributi avvengono con un semplice bonifico bancario: nessun gestore di pagamenti, nessun
+95 -54
View File
@@ -133,28 +133,20 @@
@apply bg-background text-foreground;
}
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
page reports its data has arrived. */
.page-fade {
transition: opacity 320ms ease;
}
.page-fade-hidden {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-fade {
transition: none;
}
.page-fade-hidden {
opacity: 1;
}
}
/* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part
in the cross-fade. Everything else keeps its pixels: the `day-switch`
class cancels the root animation, so the strip, header and page chrome do
not so much as flicker while the table, summary and charts swap over. */
in the cross-fade - and nothing else is captured at all. A captured
element is neither painted nor hit-testable in the live page for the
length of the animation (per spec: as if it had visibility:hidden and
pointer-events:none), and the root is captured by default, which captures
the whole page. That is what made the strip unclickable and the page
unscrollable while a switch played - no amount of pointer-events on the
overlay could fix it, because the page underneath was gone too. With the
root left un-named the strip, the chrome and the scroller keep their live
pixels and stay fully interactive. */
:root.day-switch {
view-transition-name: none;
}
:root.day-switch .day-region-table {
view-transition-name: day-table;
}
@@ -165,26 +157,87 @@
view-transition-name: day-charts;
}
/* The sticky day strip never changes during a day switch, but it has to be
captured too: view-transition snapshots all paint in one layer above the
page, so without its own group (and a higher z-index in that layer) the
fading table and charts would slide over the top of it. */
:root.day-switch .daystrip {
view-transition-name: daystrip;
}
::view-transition-group(daystrip) {
z-index: 20;
/* Region snapshots paint in a viewport-fixed layer above the live page, at
their full layout size - including the part of the table normally
scrolled up behind the sticky strip and the topbar. The strip used to be
captured purely to stay on top of that, but a captured strip is dead to
input; instead the whole overlay is clipped at the strip bar's bottom
edge (measured per switch by runDayTransition), so the snapshots stay
out of the chrome and the chrome stays live. */
:root.day-switch::view-transition {
clip-path: inset(var(--day-switch-clip, 0px) 0 0 0);
}
/* Same for the topbar: a captured region's snapshot is painted at its layout
position, including the part normally scrolled up behind the chrome. */
:root.day-switch .topbar {
/* Page swaps capture the topbar and sidebar: a region that is identical on
both sides should not be animated at all, so they are pinned here and
swapped outright below. */
:root.page-switch .topbar {
view-transition-name: topbar;
}
::view-transition-group(topbar) {
z-index: 30;
}
:root.page-switch .sidebar-region {
view-transition-name: sidebar;
}
::view-transition-group(sidebar) {
z-index: 25;
}
@media (prefers-reduced-motion: reduce) {
.day-region-table,
.day-region-summary,
.day-region-charts,
.topbar,
.sidebar-region {
view-transition-name: none;
}
}
/* The snapshot layer is a fixed overlay across the whole viewport and it
hit-tests, so for the length of an animation every click would land on it
and die - a day tapped while the previous switch is still fading simply
went dead. Let input fall through to the live page instead: by the time
the pseudo elements exist the DOM is already in its final state, and a
click that starts a new transition supersedes the running one cleanly
(see view-transition.ts). */
::view-transition {
pointer-events: none;
}
/* ── Cross-fades that do not dip ──────────────────────────────────────────
The default cross-fade is NOT opacity-neutral: coverage of two stacked
layers is `new + old * (1 - new)`, so at the midpoint the pair covers only
~75% of the region and the page background shows through both. That dip is
the flash - over the whole viewport for a page swap, and over the table,
summary and charts (between them the whole content column) for a day
switch.
Two ways out, and which one applies depends on whether the *incoming*
snapshot is opaque. Holding the outgoing one at full opacity fixes the
coverage arithmetic, but a translucent incoming snapshot then reads
straight through it and the old content lingers as a ghost. */
/* The document background is opaque, so the whole-viewport swap can simply
hold the outgoing snapshot until the pseudo elements are torn down. */
@keyframes vt-fade-in {
from {
opacity: 0;
}
}
::view-transition-old(root) {
animation: none;
opacity: 1;
}
::view-transition-new(root) {
animation: vt-fade-in 400ms ease;
}
/* The day regions are not opaque - they are cards with gaps and headings
between them - so they keep a real cross-fade and fix the dip the other
way: `plus-lighter` makes the two halves sum to exactly the original
wherever they agree, which is most of the region (the card backgrounds are
the same on both days; only the readings differ). */
::view-transition-old(day-table),
::view-transition-new(day-table),
::view-transition-old(day-summary),
@@ -193,32 +246,20 @@
::view-transition-new(day-charts) {
animation-duration: 420ms;
animation-timing-function: ease;
mix-blend-mode: plus-lighter;
}
:root.day-switch::view-transition-old(root),
:root.day-switch::view-transition-new(root) {
/* Pinned chrome swaps outright: identical on both sides, so any animation
would only risk a flicker. */
::view-transition-old(topbar),
::view-transition-old(sidebar) {
animation: none;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.day-region-table,
.day-region-summary,
.day-region-charts,
.daystrip,
.topbar {
view-transition-name: none;
}
}
/* Theme changes cross-fade the whole document in one pass (see
routes/+layout.svelte). Doing it as a view transition instead of a blanket
`* { transition }` matters: that blanket rule also stretched every
component's own hover and focus transitions to 400ms for the duration of
the switch, which read as lag on interactive controls. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 400ms;
animation-timing-function: ease;
::view-transition-new(topbar),
::view-transition-new(sidebar) {
animation: none;
opacity: 1;
}
/* Fallback for browsers without view transitions: fade the colours only. */
+3 -2
View File
@@ -15,8 +15,9 @@
<h2>1. Was Sie erhalten</h2>
<p>
Ein Unterstützerbeitrag schaltet die Extras für den bezahlten Zeitraum frei: derzeit
historisches Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie
erscheinen. Die kostenlosen Teile von Drizz.li bleiben für alle frei.
historisches Wetter mit Vergleich zu den Klimanormalen und die saisonalen Aussichten für die
kommenden Monate sowie neue Funktionen, sobald sie erscheinen. Die kostenlosen Teile von
Drizz.li bleiben für alle frei.
</p>
<h2>2. Ablauf</h2>
+2 -2
View File
@@ -14,8 +14,8 @@
<h2>1. What you get</h2>
<p>
A supporter contribution unlocks the supporter extras for the paid period: currently historical
weather with climate-normal comparisons, plus new supporter features as they land. The free
parts of Drizz.li stay free for everyone.
weather with climate-normal comparisons and the seasonal outlook for the months ahead, plus new
supporter features as they land. The free parts of Drizz.li stay free for everyone.
</p>
<h2>2. How it works</h2>
+3 -2
View File
@@ -17,8 +17,9 @@
<h2>1. Qué obtienes</h2>
<p>
Una aportación desbloquea los extras durante el periodo pagado: actualmente el clima histórico
con comparación frente a las normales climáticas, además de las nuevas funciones que vayan
llegando. Las partes gratuitas de Drizz.li siguen siendo gratuitas para todos.
con comparación frente a las normales climáticas y la perspectiva estacional de los próximos
meses, además de las nuevas funciones que vayan llegando. Las partes gratuitas de Drizz.li
siguen siendo gratuitas para todos.
</p>
<h2>2. Cómo funciona</h2>
+2 -2
View File
@@ -14,8 +14,8 @@
<h2>1. Ce que vous obtenez</h2>
<p>
Une contribution débloque les bonus pour la période payée : actuellement la météo historique
avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir. Les
parties gratuites de Drizz.li le restent pour tout le monde.
avec comparaison aux normales climatiques et l'aperçu saisonnier des mois à venir, ainsi que les
nouvelles fonctions à venir. Les parties gratuites de Drizz.li le restent pour tout le monde.
</p>
<h2>2. Fonctionnement</h2>
+3 -2
View File
@@ -14,8 +14,9 @@
<h2>1. Cosa ottieni</h2>
<p>
Un contributo sblocca gli extra per il periodo pagato: attualmente il meteo storico con
confronto rispetto alle normali climatiche, oltre alle nuove funzioni man mano che arrivano. Le
parti gratuite di Drizz.li restano gratuite per tutti.
confronto rispetto alle normali climatiche e le prospettive stagionali per i mesi a venire,
oltre alle nuove funzioni man mano che arrivano. Le parti gratuite di Drizz.li restano gratuite
per tutti.
</p>
<h2>2. Come funziona</h2>
+19 -6
View File
@@ -42,6 +42,21 @@
let subtitle = $derived(
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
);
// Joined here rather than in the markup: Svelte trims the whitespace around a
// line break, so a separator written as "{admin1},\n{country}" renders as
// "Canton of Schwyz,Switzerland". Elevation joins the same line; from lg up
// the whole line is hidden, because the topbar pill carries the region and
// elevation there.
let region = $derived(
[
location?.admin1,
location?.country,
location?.elevation != null ? `${Math.round(location.elevation)}m` : null
]
.filter(Boolean)
.join(', ')
);
</script>
{#if subtitle && location}
@@ -55,13 +70,11 @@
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
<span class="font-medium text-muted-foreground">· {subtitle}</span>
</h1>
<p class="truncate text-sm text-muted-foreground">
<span class="lg:hidden"
>{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>{subtitle}
</p>
{#if region}
<p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
{/if}
</div>
</div>
@@ -1,13 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { syncSearchParams } from '$lib/utils/url-state';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
@@ -44,7 +46,7 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null);
reportPageReady(() => fetchedData != null || loadError != null);
useHeroActions(heroActions);
@@ -56,10 +58,12 @@
storedLocation.set(data.location);
});
const DEFAULT_MODEL = 'ncep_gefs_seamless';
let params = $state({
...defaultParameters,
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
models: ['ncep_gefs_seamless']
models: [DEFAULT_MODEL]
});
// units live in a persisted store; mirror them into params so a change
@@ -94,7 +98,7 @@
$effect(() => {
const model = params.models?.[0];
if (!mounted || !model) return;
syncSearchParams(get(page).url, { model });
syncSearchParams({ model: unlessDefault(model, DEFAULT_MODEL) });
});
// components persist across refetches; entries are null while unmounted
@@ -294,9 +298,9 @@
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
<div class="flex w-full items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
selectedModel={params.models?.[0] ?? DEFAULT_MODEL}
groups={ensembleModelGroups}
label={m.model_ensemble()}
onModelChange={(model) => {
@@ -340,42 +344,52 @@
</div>
{/if}
{#if fetchedData}
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
the page margins (padded), the graphs bleed to the edges -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
{#each chartDefs as def, i (i)}
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
<div class="mb-1 px-3 lg:px-0">
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
{#if def.subtitle}
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
{/if}
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<div class="relative">
{#if fetchedData}
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
the page margins (padded), the graphs bleed to the edges -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
{#each chartDefs as def, i (i)}
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
<div class="mb-1 px-3 lg:px-0">
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
{#if def.subtitle}
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
{/if}
</div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
showCredit={def.showCredit}
zeroBaseLeft={def.zeroBaseLeft ?? true}
yMin={def.yMin}
yMax={def.yMax}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
showCredit={def.showCredit}
zeroBaseLeft={def.zeroBaseLeft ?? true}
yMin={def.yMin}
yMax={def.yMax}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div>
{/each}
</div>
{:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
{/if}
{/each}
</div>
{:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<div in:fade={{ duration: 200 }} out:skeletonOut>
<ChartContainer
loading
chartCount={params.hourly?.length || 1}
chartHeight={340}
bleed={false}
/>
</div>
{/if}
</div>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
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,154 +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 { 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();
interface TimelineExporter {
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
}
let hourlyInterval = $state<1 | 3>(3);
interface TimelinePoint {
index: number;
time: number;
x: number;
hour: string;
date: string;
}
let filteredIndices = $derived(
timestamps.reduce<number[]>((acc, ts, i) => {
if (hourlyInterval === 1 || getZonedHour(new Date(ts), timezone) % 3 === 0) {
acc.push(i);
}
return acc;
}, [])
interface 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))
);
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;
}
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++) {
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 checkNewDay(i: number, ts: number): boolean {
if (i === 0) return false;
const prevTs = timestamps[filteredIndices[i - 1]];
return (
formatZoned(new Date(ts), timezone, 'd') !== formatZoned(new Date(prevTs), timezone, 'd')
function 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. */
}
})
);
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;
}
/**
* Determines if it's daytime for each timestamp based on sunrise/sunset data.
*/
let allDaytimeFlags = $derived(
timestamps.map((ts) => {
const tsS = ts / 1000;
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;
}
// Fallback: before the first sunrise
if (sunrise.length > 0 && tsS < sunrise[0]) return false;
return true;
})
);
// 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;
/**
* Filters models that actually have weather_code data available in the response.
*/
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
const version = ++screenRenderVersion;
void getExportImage().then((rendered) => {
if (!rendered || version !== screenRenderVersion || target !== canvasEl) return;
target.width = rendered.width;
target.height = rendered.height;
const context = target.getContext('2d');
if (!context) return;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, target.width, target.height);
context.drawImage(rendered, 0, 0);
});
return () => {
if (version === screenRenderVersion) screenRenderVersion++;
};
});
</script>
{#snippet weatherIcon(name: string, size: number = 24)}
<svg class="inline-block fill-foreground" width={size} height={size}>
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg>
{/snippet}
{#if displayModels.length > 0}
<div class="mt-8">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-bold">{m.compare_timeline_title()}</h3>
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
<span class="select-none text-muted-foreground">3h</span>
<button
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
title={m.interval_toggle()}
>
<span
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
{hourlyInterval === 1 ? 'left-5.5' : 'left-0.75'}"
></span>
</button>
<span class="select-none text-muted-foreground">1h</span>
<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
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}
>
{showModelNames ? m.compare_hide_model_names() : m.compare_show_model_names()}
</button>
</div>
<div class="lg:px-4">
<div
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}
>
<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'}
>
<div class="mb-1 font-semibold whitespace-nowrap">
{formatZoned(new Date(sharedHover * 1000), timezone, 'EEE d MMM HH:mm')}
</div>
{#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}
</div>
</div>
<div
class="overflow-x-auto rounded-lg border border-border bg-card shadow-sm"
style="scrollbar-width: thin"
>
<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"
>
{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>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each displayModels as model (model)}
<tr class="group hover:bg-muted/10">
<td
class="sticky left-0 z-10 border-b border-r border-border bg-card p-2 text-[11px] font-semibold group-hover:bg-muted/20"
>
{model.replace(/_/g, ' ')}
</td>
{#each filteredIndices as idx, i (idx)}
{@const ts = timestamps[idx]}
{@const codes = hourlyFlat[`weather_code_${model}`]}
{@const code = codes ? codes[idx] : 0}
{@const day = allDaytimeFlags[idx]}
{@const isNewDay = checkNewDay(i, ts)}
<td
class="border-b border-r border-border/30 p-1.5 text-center {isNewDay
? 'border-l-2 border-l-primary/20'
: ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}"
>
{@render weatherIcon(getWeatherIconName(code, day))}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</section>
{/if}
<style>
th,
td {
white-space: nowrap;
}
</style>
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
cardinalDirection,
isWindDirection,
modelColor,
modelMean,
sanitizeList,
validDirectionValues
} from './comparison';
import type { ModelSeriesData } from '$lib/services/weather';
const models: ModelSeriesData[] = [
{
modelId: 'model-a',
resolvedModelId: 'model-a',
variables: {
precipitation: [1, Number.NaN, 3],
precipitation_probability: [80, 90, 100]
}
},
{
modelId: 'model-b',
resolvedModelId: 'model-b',
variables: { precipitation: [3, Number.NaN, 5] }
}
];
describe('multimodel comparison helpers', () => {
it('averages only the exact scalar variable and preserves missing values', () => {
expect(modelMean(models, 'precipitation', 3)).toEqual([2, null, 4]);
});
it('validates and deduplicates shared URL selections', () => {
const allowed = new Set(['a', 'b']);
expect(sanitizeList(['a', 'unknown', 'a', 'b'], allowed)).toEqual(['a', 'b']);
expect(sanitizeList(['unknown'], allowed)).toBeNull();
});
it('treats every wind direction height as a direction series', () => {
expect(isWindDirection('wind_direction_10m')).toBe(true);
expect(isWindDirection('wind_direction_180m')).toBe(true);
expect(isWindDirection('wind_speed_10m')).toBe(false);
});
it('drops direction values outside the fixed 0360 degree domain', () => {
expect(validDirectionValues([-1, 0, 90, 360, 361, Number.NaN])).toEqual([
null,
0,
90,
360,
null,
null
]);
expect(cardinalDirection(0)).toBe('N');
expect(cardinalDirection(270)).toBe('W');
});
it('assigns deterministic model colors', () => {
expect(modelColor('ecmwf_ifs')).toBe(modelColor('ecmwf_ifs'));
expect(modelColor('ecmwf_ifs')).not.toBe(modelColor('gfs_global'));
});
});
@@ -0,0 +1,130 @@
import { findModel, models as modelOptions } from '../../options';
import type { ModelSeriesData } from '$lib/services/weather';
const MODEL_COLORS = [
'#0072b2',
'#d55e00',
'#009e73',
'#cc79a7',
'#e69f00',
'#56b4e9',
'#6f4ead',
'#8c564b',
'#e11d48',
'#65a30d',
'#0891b2',
'#c026d3',
'#2563eb',
'#ea580c',
'#059669',
'#9333ea'
] as const;
/**
* High-contrast categorical colour. Within a comparison, selection order owns
* the palette slot so adjacent series never receive accidentally similar hues.
*/
export function modelColor(modelId: string, modelOrder?: readonly string[]): string {
const selectedIndex = modelOrder?.indexOf(modelId) ?? -1;
if (selectedIndex >= 0) return MODEL_COLORS[selectedIndex % MODEL_COLORS.length]!;
const canonicalIndex = modelOptions.findIndex((model) => model.value === modelId);
if (canonicalIndex >= 0) return MODEL_COLORS[canonicalIndex % MODEL_COLORS.length]!;
let hash = 2166136261;
for (let i = 0; i < modelId.length; i++) {
hash ^= modelId.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return MODEL_COLORS[Math.abs(hash) % MODEL_COLORS.length]!;
}
export function modelLabel(modelId: string): string {
return findModel(modelId)?.label ?? modelId.replace(/_/g, ' ');
}
export function sanitizeList(
values: string[] | null,
allowed: ReadonlySet<string>
): string[] | null {
if (!values) return null;
const clean = [...new Set(values.filter((value) => allowed.has(value)))];
return clean.length > 0 ? clean : null;
}
export function isWindDirection(variable: string): boolean {
return variable.startsWith('wind_direction_');
}
export function validDirectionValues(values: number[] | undefined): (number | null)[] {
return (values ?? []).map((value) =>
Number.isFinite(value) && value >= 0 && value <= 360 ? value : null
);
}
export interface PrecipitationAgreementPoint {
wetCount: number;
availableCount: number;
median: number;
min: number;
max: number;
}
/** Per-timestamp precipitation occurrence agreement and amount spread. */
export function precipitationAgreement(
models: ModelSeriesData[],
variable: string,
timeLength: number,
wetThreshold: number
): (PrecipitationAgreementPoint | null)[] {
const points: (PrecipitationAgreementPoint | null)[] = new Array(timeLength).fill(null);
for (let index = 0; index < timeLength; index++) {
const values: number[] = [];
for (const model of models) {
const value = model.variables[variable]?.[index];
if (value === undefined || !Number.isFinite(value) || value < 0) continue;
values.push(value);
}
if (values.length === 0) continue;
values.sort((left, right) => left - right);
const middle = Math.floor(values.length / 2);
const median =
values.length % 2 === 0 ? (values[middle - 1] + values[middle]) / 2 : values[middle];
points[index] = {
wetCount: values.filter((value) => value > wetThreshold).length,
availableCount: values.length,
median,
min: values[0],
max: values[values.length - 1]
};
}
return points;
}
export function cardinalDirection(value: number): string {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
return directions[Math.round((((value % 360) + 360) % 360) / 45) % 8];
}
/** Arithmetic model mean for scalar variables; missing consensus stays missing. */
export function modelMean(
models: ModelSeriesData[],
variable: string,
timeLength: number
): (number | null)[] {
const totals = new Array<number>(timeLength).fill(0);
const counts = new Array<number>(timeLength).fill(0);
for (const model of models) {
const values = model.variables[variable] ?? [];
for (let i = 0; i < Math.min(values.length, timeLength); i++) {
const value = values[i];
if (!Number.isFinite(value)) continue;
totals[i] += value;
counts[i]++;
}
}
return totals.map((total, i) =>
counts[i] > 0 ? Math.round((total / counts[i]) * 10) / 10 : null
);
}
@@ -13,17 +13,19 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type HistoricalForecastResult,
fetchClimateNormals,
fetchHistoricalWeather
} from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte';
import { archiveModelGroups, defaultParameters } from '../../options';
@@ -39,8 +41,10 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
// The page cross-fade waits for this before revealing the new page - and so
// does the loading overlay, so a visitor without a key has to count as ready:
// the locked panel is the finished page here, nothing is on its way.
reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -213,13 +217,15 @@
);
function switchDay(date: Date) {
selectedDay.setTime(date.getTime());
// see the week page: an unformattable selected day breaks every consumer
const time = date?.getTime();
if (Number.isFinite(time)) selectedDay.setTime(time);
}
</script>
<!-- the reanalysis picker rides in the layout's location row -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={archiveModel}
groups={archiveModelGroups}
@@ -237,7 +243,7 @@
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head>
<PaywallGate feature={m.page_historical_subtitle()}>
<SupporterGate feature={m.page_historical_subtitle()}>
<DateRangeControls
start={startDate}
end={endDate}
@@ -254,7 +260,7 @@
</div>
{/if}
<div class="mt-4">
<div class="relative mt-4">
{#if result && fetchedHourly && fetchedDaily}
<HistoricalDaily
daily={result.daily}
@@ -278,10 +284,10 @@
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
{:else}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
</div>
{/if}
</div>
</PaywallGate>
</SupporterGate>
@@ -9,7 +9,7 @@
} from '$lib/services/weather';
import { getColor, getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
interface Props {
@@ -202,7 +202,8 @@
>{formatZoned(date, timezone, 'd MMM')}</span
>
<svg class="my-0.5" width="20" height="20" aria-hidden="true">
<svg class="my-0.5" width="20" height="20">
<title>{getWeatherDescription(daily.weather_code[i])}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
daily.weather_code[i],
@@ -31,7 +31,7 @@
let chartComponents: (CanvasChart | null)[] = $state([]);
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null));
// Same customizable layout as the 7-day meteograms, so the two pages match.
// Same customizable layout as the week-page meteograms, so the two pages match.
let panels = $derived(
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
);
+35 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { mapTransitionCover, reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
import { mapsDomainForModel } from '$lib/utils/maps-domain';
@@ -16,6 +18,23 @@
let hashOverride = $state<string | null>(null);
let iframeEl = $state<HTMLIFrameElement | null>(null);
let mapReady = $state(false);
let frameLoaded = $state(false);
// The map is a cross-origin iframe: until it has loaded there is nothing here
// but an empty panel, and a page transition that ends before then cross-fades
// the old page into that blank. Reporting readiness holds the transition (and
// then the loading overlay) until there is actually a map to fade into.
// `om-maps:ready` is the good signal; the iframe's own load event is the
// backstop, so a map that fails to boot still releases the page.
reportPageReady(() => mapReady || frameLoaded);
// The map stays under an opaque cover until it is ready, so it fades up from
// a clean panel instead of flashing the iframe's white boot document (worst
// in dark mode). The same cover is what makes navigation transitions work at
// all here: a cross-origin iframe is never painted into a view transition
// snapshot, so the cover is raised again just before a departure is captured
// (see mapTransitionCover) - both snapshots then hold real pixels.
let revealed = $derived(mapReady || frameLoaded);
const postToMap = (message: Record<string, unknown>) => {
iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN);
@@ -83,7 +102,7 @@
<!-- Full-bleed map: the layout drops its padding for this route. The map
follows our theme through the color-scheme declared on :root/.dark -->
<div class="h-full w-full bg-background">
<div class="relative h-full w-full bg-background">
<!-- allow="cross-origin-isolated" delegates SharedArrayBuffer use to the
map; it only takes effect when this site itself is served with
COOP/COEP headers (see README, Deployment) -->
@@ -95,7 +114,22 @@
allowfullscreen
allow="cross-origin-isolated"
referrerpolicy="no-referrer"
onload={() => (frameLoaded = true)}
class="block h-full w-full border-0"
class:invisible={$mapTransitionCover}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe>
<!-- No `in:` transition on purpose: when the cover comes back for a
departure it has to be at full opacity by the time the snapshot is
taken, not fading towards it. The iframe is additionally made
invisible then (above), so the capture never has to paint cross-origin
content at all - some engines degrade the whole transition over it. -->
{#if !revealed || $mapTransitionCover}
<div
class="absolute inset-0 bg-background"
out:fade={{ duration: 300 }}
aria-hidden="true"
></div>
{/if}
</div>
+64 -71
View File
@@ -27,9 +27,57 @@ export interface WeatherModelGroup {
*/
export const modelGroups: WeatherModelGroup[] = [
{
value: 'auto',
label: 'Automatic',
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }]
value: 'automatic_seamless',
label: 'Automatic & seamless',
models: [
{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' },
{
value: 'icon_seamless',
label: 'DWD ICON Seamless',
resolution: '2-13 km',
update: 'every 3 h'
},
{ value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' },
{
value: 'meteofrance_seamless',
label: 'MF Seamless',
resolution: '1-25 km',
update: 'every 3 h'
},
{
value: 'ukmo_seamless',
label: 'UKMO Seamless',
resolution: '2-10 km',
update: 'every 3 h'
},
{
value: 'knmi_seamless',
label: 'KNMI Seamless',
resolution: '2-3 km',
update: 'every hour'
},
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
{
value: 'metno_seamless',
label: 'MET Norway Seamless',
resolution: '1 km',
update: 'every 3 h'
},
{
value: 'meteoswiss_icon_seamless',
label: 'MeteoSwiss ICON Seamless',
resolution: '1-2 km',
update: 'every 3 h'
},
{
value: 'chmi_aladin_seamless',
label: 'CHMI Aladin Seamless',
resolution: '1-2.3 km',
update: 'every 6 h'
},
{ value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' },
{ value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' }
]
},
{
value: 'ecmwf',
@@ -49,12 +97,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'dwd',
label: 'DWD Germany',
models: [
{
value: 'icon_seamless',
label: 'DWD ICON Seamless',
resolution: '2-13 km',
update: 'every 3 h'
},
{ value: 'icon_global', label: 'DWD ICON', resolution: '13 km', update: 'every 6 h' },
{ value: 'icon_eu', label: 'DWD ICON EU', resolution: '7 km', update: 'every 3 h' },
{ value: 'icon_d2', label: 'DWD ICON D2', resolution: '2 km', update: 'every 3 h' }
@@ -64,7 +106,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'ncep',
label: 'NOAA U.S.',
models: [
{ value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' },
{ value: 'gfs_global', label: 'GFS Global', resolution: '13 km', update: 'every 6 h' },
{ value: 'gfs_hrrr', label: 'GFS HRRR Conus', resolution: '3 km', update: 'every hour' },
{
@@ -98,12 +139,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'meteofrance',
label: 'Météo-France',
models: [
{
value: 'meteofrance_seamless',
label: 'MF Seamless',
resolution: '1-25 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arpege_world',
label: 'MF ARPEGE World',
@@ -134,12 +169,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'ukmo',
label: 'UK Met Office',
models: [
{
value: 'ukmo_seamless',
label: 'UKMO Seamless',
resolution: '2-10 km',
update: 'every 3 h'
},
{
value: 'ukmo_global_deterministic_10km',
label: 'UK Met Office 10km',
@@ -158,12 +187,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'knmi',
label: 'KNMI Netherlands',
models: [
{
value: 'knmi_seamless',
label: 'KNMI Seamless',
resolution: '2-3 km',
update: 'every hour'
},
{
value: 'knmi_harmonie_arome_europe',
label: 'KNMI Harmonie Arome Europe',
@@ -182,7 +205,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'dmi',
label: 'DMI Denmark',
models: [
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
{
value: 'dmi_harmonie_arome_europe',
label: 'DMI Harmonie Arome Europe',
@@ -195,12 +217,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'metno',
label: 'MET Norway',
models: [
{
value: 'metno_seamless',
label: 'MET Norway Seamless',
resolution: '1 km',
update: 'every 3 h'
},
{ value: 'metno_nordic', label: 'MET Norway Nordic', resolution: '1 km', update: 'every 3 h' }
]
},
@@ -208,12 +224,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'meteoswiss',
label: 'MeteoSwiss',
models: [
{
value: 'meteoswiss_icon_seamless',
label: 'MeteoSwiss ICON Seamless',
resolution: '1-2 km',
update: 'every 3 h'
},
{
value: 'meteoswiss_icon_ch1',
label: 'MeteoSwiss ICON CH1',
@@ -228,25 +238,10 @@ export const modelGroups: WeatherModelGroup[] = [
}
]
},
{
value: 'kma',
label: 'KMA Korea',
models: [
{
value: 'kma_seamless',
label: 'KMA Seamless',
resolution: '1.5-13 km',
update: 'every 3 h'
},
{ value: 'kma_ldps', label: 'KMA LDPS', resolution: '1.5 km', update: 'every 3 h' },
{ value: 'kma_gdps', label: 'KMA GDPS 12km', resolution: '13 km', update: 'every 3 h' }
]
},
{
value: 'jma',
label: 'JMA Japan',
models: [
{ value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' },
{ value: 'jma_msm', label: 'JMA MSM', resolution: '5 km', update: 'every 3 h' },
{ value: 'jma_gsm', label: 'JMA GSM', resolution: '55 km', update: 'every 6 h' }
]
@@ -264,14 +259,20 @@ export const modelGroups: WeatherModelGroup[] = [
]
},
{
value: 'bom',
label: 'BOM Australia',
value: 'chmi',
label: 'CHMI Czech Republic',
models: [
{
value: 'bom_access_global',
label: 'BOM ACCESS Global',
resolution: '15 km',
update: 'every 12 h'
value: 'chmi_aladin_central_europe_2km',
label: 'CHMI Aladin Central Europe 2km',
resolution: '2.3 km',
update: 'every 6 h'
},
{
value: 'chmi_aladin_cz_1km',
label: 'CHMI Aladin CZ 1km',
resolution: '1 km',
update: 'every 6 h'
}
]
},
@@ -279,7 +280,6 @@ export const modelGroups: WeatherModelGroup[] = [
value: 'cmc_gem',
label: 'GEM Canada',
models: [
{ value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' },
{ value: 'gem_global', label: 'GEM Global', resolution: '15 km', update: 'every 12 h' },
{ value: 'gem_regional', label: 'GEM Regional', resolution: '10 km', update: 'every 6 h' },
{
@@ -433,11 +433,6 @@ export const ensembleModelGroups: WeatherModelGroup[] = [
}
]
},
{
value: 'bom',
label: 'BOM Australia',
models: [{ value: 'bom_access_global_ensemble', label: 'BOM ACCESS Global' }]
},
{
value: 'ukmo',
label: 'UK Met Office',
@@ -503,9 +498,7 @@ const IN_DOMAIN_CITY: [string, { slug: string; label: string }][] = [
['nbm', { slug: 'new-york', label: 'New York' }],
['gem', { slug: 'toronto', label: 'Toronto' }],
['jma', { slug: 'tokyo', label: 'Tokyo' }],
['kma', { slug: 'seoul', label: 'Seoul' }],
['cma', { slug: 'beijing', label: 'Beijing' }],
['bom', { slug: 'sydney', label: 'Sydney' }]
['cma', { slug: 'beijing', label: 'Beijing' }]
];
/** Where this model definitely has data, or null for a global model. */
@@ -6,19 +6,21 @@
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type SeasonalForecastResult,
fetchClimateNormals,
fetchSeasonalForecast
} from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, seasonalModelGroups } from '../../options';
@@ -33,8 +35,10 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
// The page cross-fade waits for this before revealing the new page - and so
// does the loading overlay, so a visitor without a key has to count as ready:
// the locked panel is the finished page here, nothing is on its way.
reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -189,16 +193,27 @@
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<!-- Out of flow on lg+ (the hero row is `relative`), same as the week, 14-day
and archive pages: the controls then cannot move the heading when they
change size. -->
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
{#if result}
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
<!-- Range buttons reslice the already-fetched horizon (no refetch). While a
forecast is on its way they stay mounted but invisible, because
mounting them on arrival re-flowed the row and nudged the heading.
Two exceptions, both of which would reserve a gap for something that
is never coming: below sm the group is a full-width row of its own,
and behind the supporter gate there is no forecast on its way at all. -->
{#if $isSupporter}
<div
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto {result
? ''
: 'hidden sm:flex sm:invisible'}"
role="group"
aria-label={m.seasonal_range_aria()}
aria-hidden={!result}
>
{#each RANGES as range, i (range.label)}
{@const disabled = range.days !== Infinity && range.days > horizonDays}
{@const disabled = !result || (range.days !== Infinity && range.days > horizonDays)}
<button
type="button"
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
@@ -209,6 +224,7 @@
: ''}"
aria-pressed={rangeIndex === i}
{disabled}
tabindex={result ? undefined : -1}
onclick={() => (rangeIndex = i)}
>
{range.label}
@@ -253,7 +269,7 @@
</p>
</div>
<PaywallGate feature={m.page_seasonal_subtitle()}>
<SupporterGate feature={m.page_seasonal_subtitle()}>
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
@@ -262,40 +278,44 @@
</div>
{/if}
{#if visible && months.length > 0}
<SeasonalMonths {months} units={params} {normals} />
<!-- `relative` anchors the placeholder while it fades out over the real
outlook rather than holding its own slot in the layout (skeletonOut). -->
<div class="relative">
{#if visible && months.length > 0}
<SeasonalMonths {months} units={params} {normals} />
<div class="mt-6">
<SeasonalCharts
result={visible}
{normals}
units={params}
{loading}
{showLegend}
bind:charts={chartComponents}
/>
</div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
{#snippet controls()}
<div class="flex items-center gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none"
>{m.legend_show()}</Label
>
</div>
{/snippet}
</ChartToolbar>
</div>
{:else if !loadError}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
{/each}
<div class="mt-6">
<SeasonalCharts
result={visible}
{normals}
units={params}
{loading}
{showLegend}
bind:charts={chartComponents}
/>
</div>
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
</div>
{/if}
</PaywallGate>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
{#snippet controls()}
<div class="flex items-center gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none"
>{m.legend_show()}</Label
>
</div>
{/snippet}
</ChartToolbar>
</div>
{:else if !loadError}
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
{/each}
</div>
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
</div>
{/if}
</div>
</SupporterGate>
@@ -0,0 +1,129 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
computeDayNightWeatherCodes,
getWeatherDescription,
getWeatherIconName
} from './weather-codes';
const ICON_DIR = join(process.cwd(), 'static/images/weather-icons');
// The codes open-meteo actually emits (WMO 4677 subset), with the family each
// one belongs to. The icon mapping used to be built for a different code table,
// which is how fog ended up showing hail and a hail thunderstorm a tornado -
// mismatches nothing in the type system could catch.
const OPEN_METEO_CODES: Record<number, { meaning: string; family: RegExp }> = {
0: { meaning: 'Clear sky', family: /clear/ },
1: { meaning: 'Mainly clear', family: /clear|cloudy/ },
2: { meaning: 'Partly cloudy', family: /cloudy/ },
3: { meaning: 'Overcast', family: /cloudy/ },
45: { meaning: 'Fog', family: /fog/ },
48: { meaning: 'Depositing rime fog', family: /fog/ },
51: { meaning: 'Light drizzle', family: /sprinkle|showers/ },
53: { meaning: 'Moderate drizzle', family: /sprinkle|rain/ },
55: { meaning: 'Dense drizzle', family: /sprinkle|rain/ },
56: { meaning: 'Light freezing drizzle', family: /rain-mix|sleet/ },
57: { meaning: 'Dense freezing drizzle', family: /rain-mix|sleet/ },
61: { meaning: 'Slight rain', family: /rain|sprinkle|showers/ },
63: { meaning: 'Moderate rain', family: /rain/ },
65: { meaning: 'Heavy rain', family: /rain/ },
66: { meaning: 'Light freezing rain', family: /rain-mix|sleet/ },
67: { meaning: 'Heavy freezing rain', family: /rain-mix|sleet/ },
71: { meaning: 'Slight snowfall', family: /snow/ },
73: { meaning: 'Moderate snowfall', family: /snow/ },
75: { meaning: 'Heavy snowfall', family: /snow/ },
77: { meaning: 'Snow grains', family: /snow/ },
80: { meaning: 'Slight rain showers', family: /showers|rain/ },
81: { meaning: 'Moderate rain showers', family: /showers|rain/ },
82: { meaning: 'Violent rain showers', family: /showers|rain/ },
85: { meaning: 'Slight snow showers', family: /snow/ },
86: { meaning: 'Heavy snow showers', family: /snow/ },
95: { meaning: 'Thunderstorm', family: /thunderstorm|storm-showers|lightning/ },
96: { meaning: 'Thunderstorm with slight hail', family: /thunderstorm|storm-showers|hail/ },
99: { meaning: 'Thunderstorm with heavy hail', family: /thunderstorm|storm-showers|hail/ }
};
const codes = Object.keys(OPEN_METEO_CODES).map(Number);
describe('getWeatherIconName', () => {
it.each(codes)('code %i resolves to icon files that exist', (code) => {
for (const daytime of [true, false]) {
const name = getWeatherIconName(code, daytime);
expect(existsSync(join(ICON_DIR, `${name}.svg`)), `${name}.svg missing`).toBe(true);
}
});
it.each(codes)('code %i uses a glyph that matches its meaning', (code) => {
const { meaning, family } = OPEN_METEO_CODES[code];
for (const daytime of [true, false]) {
const name = getWeatherIconName(code, daytime);
expect(name, `${code} (${meaning}) → ${name}`).toMatch(family);
}
});
it('never shows a tornado: open-meteo has no tornado code', () => {
const names = codes.flatMap((c) => [getWeatherIconName(c, true), getWeatherIconName(c, false)]);
expect(names.some((n) => n.includes('tornado'))).toBe(false);
});
it('distinguishes overcast from partly cloudy', () => {
expect(getWeatherIconName(3, true)).not.toBe(getWeatherIconName(2, true));
});
it('falls back to a clear glyph for codes it does not know', () => {
expect(getWeatherIconName(12345, true)).toBe('wi-day-clear');
});
});
describe('getWeatherDescription', () => {
it.each(codes)('code %i has a description', (code) => {
expect(getWeatherDescription(code).length).toBeGreaterThan(0);
});
it('returns nothing for unknown or missing codes', () => {
expect(getWeatherDescription(12345)).toBe('');
expect(getWeatherDescription(null)).toBe('');
expect(getWeatherDescription(undefined)).toBe('');
expect(getWeatherDescription(NaN)).toBe('');
});
});
describe('daily aggregation', () => {
const DAY = 24 * 3600;
// One synthetic day: sunrise 06:00, sunset 20:00, hourly codes from 00:00.
const hourly = (codes: number[]) => codes.map((_, i) => i * 3600 * 1000);
const run = (hourCodes: number[]) =>
computeDayNightWeatherCodes(hourly(hourCodes), hourCodes, [6 * 3600], [20 * 3600]).day[0];
const mostlyClear = (overrides: Record<number, number>) =>
Array.from({ length: DAY / 3600 }, (_, h) => overrides[h] ?? 0);
it('lets a single thundery hour lead the day, as before', () => {
expect(run(mostlyClear({ 18: 95 }))).toBe(95);
});
it('picks the plain thunderstorm when a lone 99 ties with a 95', () => {
// the reported case: one hour of 99 next to one hour of 95 used to escalate
// the whole day card to the most extreme code on the scale
expect(run(mostlyClear({ 18: 99, 19: 95 }))).toBe(95);
});
it('still shows 99 when it is the only thunder code', () => {
expect(run(mostlyClear({ 18: 99 }))).toBe(99);
});
it('follows frequency before severity within thunder', () => {
expect(run(mostlyClear({ 15: 99, 16: 99, 17: 95 }))).toBe(99);
});
it('keeps preferring the heavier code on a tie outside thunder', () => {
// 65 (heavy rain) over 80 (slight showers) - the open-meteo max-code flaw
expect(run(mostlyClear({ 12: 65, 13: 80 }))).toBe(65);
});
it('does not let one foggy hour brand the day', () => {
expect(run(mostlyClear({ 7: 45 }))).toBe(0);
});
});
+91 -23
View File
@@ -1,8 +1,10 @@
import * as m from '$lib/paraglide/messages';
const weatherCodes: Record<number, string> = {
0: 'clear',
1: 'clear',
2: 'cloudy',
3: 'cloudy',
3: 'overcast',
4: 'fog',
5: 'fog',
10: 'fog',
@@ -30,26 +32,26 @@ const weatherCodes: Record<number, string> = {
42: 'rain',
43: 'sprinkle',
44: 'rain',
45: 'hail',
45: 'fog',
46: 'hail',
47: 'snow',
48: 'snow',
48: 'fog',
50: 'sprinkle',
51: 'sprinkle',
52: 'rain',
53: 'rain',
53: 'sprinkle',
54: 'sprinkle',
55: 'rain',
56: 'rain-mix',
57: 'sprinkle',
57: 'rain-mix',
58: 'rain',
60: 'sprinkle',
61: 'sprinkle',
62: 'rain',
63: 'rain',
64: 'hail',
65: 'hail',
66: 'hail',
65: 'rain',
66: 'rain-mix',
67: 'rain-mix',
68: 'rain-mix',
70: 'snow',
@@ -61,13 +63,13 @@ const weatherCodes: Record<number, string> = {
76: 'snow',
77: 'snow',
78: 'snow',
80: 'rain',
81: 'sprinkle',
80: 'showers',
81: 'showers',
82: 'rain',
83: 'rain',
84: 'storm-showers',
85: 'rain-mix',
86: 'rain-mix',
85: 'snow',
86: 'snow',
87: 'rain-mix',
89: 'hail',
90: 'lightning',
@@ -77,18 +79,76 @@ const weatherCodes: Record<number, string> = {
94: 'lightning',
95: 'thunderstorm',
96: 'thunderstorm',
99: 'tornado'
99: 'storm-showers'
};
// These conditions ship only as a single neutral glyph (no day/night variant).
const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']);
// Conditions that ship as a single neutral glyph (no day/night variant). The
// file is not always wi-<name>: 'overcast' uses the flat cloud, which keeps it
// distinct from 'cloudy' (code 2), whose glyph carries a sun or moon.
const NEUTRAL_ICONS: Record<string, string> = {
'snowflake-cold': 'wi-snowflake-cold',
'strong-wind': 'wi-strong-wind',
dust: 'wi-dust',
tornado: 'wi-tornado',
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';
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`;
const neutral = NEUTRAL_ICONS[name];
if (neutral) return neutral;
return `wi-${daytime ? 'day' : 'night'}-${name}`;
}
// Plain-language name for each code open-meteo actually emits (WMO 4677 subset),
// used as the hover title on the pictograms. A glyph alone is ambiguous - the
// hail-thunderstorm swirl in particular reads as something far more dramatic
// than "thunderstorm with heavy hail".
const WMO_DESCRIPTIONS: Record<number, () => string> = {
0: m.wmo_0,
1: m.wmo_1,
2: m.wmo_2,
3: m.wmo_3,
45: m.wmo_45,
48: m.wmo_48,
51: m.wmo_51,
53: m.wmo_53,
55: m.wmo_55,
56: m.wmo_56,
57: m.wmo_57,
61: m.wmo_61,
63: m.wmo_63,
65: m.wmo_65,
66: m.wmo_66,
67: m.wmo_67,
71: m.wmo_71,
73: m.wmo_73,
75: m.wmo_75,
77: m.wmo_77,
80: m.wmo_80,
81: m.wmo_81,
82: m.wmo_82,
85: m.wmo_85,
86: m.wmo_86,
95: m.wmo_95,
96: m.wmo_96,
99: m.wmo_99
};
/** Localized condition text for a weather code; '' for codes we have no name for. */
export function getWeatherDescription(code: number | null | undefined): string {
if (code == null || !Number.isFinite(code)) return '';
return WMO_DESCRIPTIONS[code]?.() ?? '';
}
// ─── Local day/night weather codes ──────────────────────────────────────────
// Open-meteo's daily weather_code is a plain numeric max over all 24 hourly
// codes (VariableDaily.swift: `.max(.weathercode)`) — there is no day/night
@@ -113,8 +173,17 @@ const FOG = new Set([45, 48]);
// counterparts when breaking frequency ties (fixes 80 "outranking" 65).
const INTENSITY_EQUIV: Record<number, number> = { 80: 61, 81: 63, 82: 65, 77: 71, 85: 71, 86: 75 };
/** Most frequent code; ties go to the more intense (then higher) code. */
function modeWithHighTiebreak(codes: number[]): number {
/**
* Most frequent code; ties go to the more intense (then higher) code, or with
* `preferLower` to the least intense one.
*
* Thunder is the one group that ties downwards. Within it the code only
* describes how much hail comes with the storm, and letting the worst of them
* win a coin-flip tie is how a single hour of 99 used to brand a whole day as
* the most extreme thing on the scale. Elsewhere the heavier code winning a tie
* is the point (heavy rain over slight showers).
*/
function modeWithHighTiebreak(codes: number[], preferLower = false): number {
const counts = new Map<number, number>();
for (const c of codes) counts.set(c, (counts.get(c) ?? 0) + 1);
let best = codes[0];
@@ -122,11 +191,10 @@ function modeWithHighTiebreak(codes: number[]): number {
for (const [code, count] of counts) {
const intensity = INTENSITY_EQUIV[code] ?? code;
const bestIntensity = INTENSITY_EQUIV[best] ?? best;
if (
count > bestCount ||
(count === bestCount &&
(intensity > bestIntensity || (intensity === bestIntensity && code > best)))
) {
const winsTie = preferLower
? intensity < bestIntensity || (intensity === bestIntensity && code < best)
: intensity > bestIntensity || (intensity === bestIntensity && code > best);
if (count > bestCount || (count === bestCount && winsTie)) {
best = code;
bestCount = count;
}
@@ -144,7 +212,7 @@ function daypartCode(codes: number[]): number | null {
// (open-meteo's "don't hide hazards" philosophy, kept per-group).
for (const group of [THUNDER, FREEZING, SNOW, RAIN, DRIZZLE]) {
const hits = hours.filter((c) => group.has(c));
if (hits.length > 0) return modeWithHighTiebreak(hits);
if (hits.length > 0) return modeWithHighTiebreak(hits, group === THUNDER);
}
// Fog needs persistence (≥2h and ≥¼ of the daypart) so one misty hour at
+132 -85
View File
@@ -18,7 +18,8 @@
import { formatZoned } from '$lib/utils/date';
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location';
import { syncSearchParams } from '$lib/utils/url-state';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer } from '$lib/components/charts';
@@ -49,7 +50,7 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedDaily != null && fetchedHourly != null);
reportPageReady(() => (fetchedDaily != null && fetchedHourly != null) || loadError != null);
useHeroActions(heroActions);
@@ -85,10 +86,21 @@
// arrives (no layout shift)
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
// The hourly table is a header row plus one row per enabled variable, so the
// placeholder below reserves exactly that and nothing under it jumps when the
// real table arrives.
const TABLE_ROW_PX = 57;
// The hourly table is a header row, the time/daylight row, then one row per
// enabled variable, so the placeholder below reserves exactly that and
// nothing under it jumps when the real table arrives. Rows are shorter below
// lg, where the cells sit tighter - measured, not guessed: 36px against 55px,
// and the time row is 48px at every width.
let compactRows = $state(false);
onMount(() => {
const mq = window.matchMedia('(max-width: 1023px)');
const apply = () => (compactRows = mq.matches);
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
let tableRowPx = $derived(compactRows ? 36 : 55);
const TABLE_TIME_ROW_PX = 48;
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
// Request only the hourly variables the table rows and meteograms actually
@@ -200,8 +212,13 @@
// Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
// A model that answers with a broken timestamp must not be able to park an
// unreadable date in `selectedDay`: everything downstream formats it, and a
// date that cannot be formatted takes the page with it.
const switchDay = (date: Date) => {
runDayTransition(() => selectedDay.setTime(date.getTime()));
const time = date?.getTime();
if (!Number.isFinite(time)) return;
runDayTransition(() => selectedDay.setTime(time));
};
onMount(() => {
@@ -256,9 +273,9 @@
const isToday = fetchedDaily
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
: false;
syncSearchParams(get(page).url, {
syncSearchParams({
day: isToday ? null : dayKey,
model: model && model !== 'best_match' ? model : null
model: unlessDefault(model, 'best_match')
});
});
@@ -329,12 +346,12 @@
<svelte:head>
<title>Drizz.li | {m.page_week_title()}</title>
<link rel="canonical" href="https://drizz.li/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
<meta name="description" content="Weekly weather forecast with detailed hourly data" />
</svelte:head>
<!-- the model picker rides in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
@@ -457,85 +474,115 @@
{locationRoute}
/>
{#if fetchedHourly && fetchedDaily}
<div class="day-region-table" use:daySwap={selectedDayKey}>
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
/>
</div>
{:else}
<!-- Mirrors the real table: same header bar and the same body height,
so the heading doesn't pop in and nothing below moves.
Placeholders only fade IN - a fade-out would keep them in the
layout while the real content mounts below, and everything below
would jump the moment they finally unmount. -->
<div
in:fade={{ duration: 200 }}
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
>
<!-- `relative` is what lets the placeholder fade out on top of the table
instead of holding a second slot in the layout (see skeletonOut). -->
<div class="relative">
{#if fetchedHourly && fetchedDaily}
<div class="day-region-table" use:daySwap={selectedDayKey}>
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
/>
</div>
{:else}
<!-- Mirrors the real table: same header bar and the same body height,
so the heading doesn't pop in and nothing below moves. -->
<div
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
in:fade={{ duration: 200 }}
out:skeletonOut
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
>
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
<div class="flex items-center gap-2">
<div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div>
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
</div>
</div>
<!-- one placeholder per row the real table will render, so the body
reads as a loading table rather than a blank panel -->
<div class="divide-y divide-border/50">
{#each { length: enabledTableRows } as _, i (i)}
<div class="flex items-center gap-4 px-4" style="height: {TABLE_ROW_PX}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
<div
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
>
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
<div class="flex items-center gap-2">
<div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div>
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
</div>
{/each}
</div>
</div>
{/if}
{#if fetchedHourly && fetchedDaily}
<div class="day-region-summary" use:daySwap={selectedDayKey}>
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
</div>
{:else}
<!-- same footprint as the written forecast, so it doesn't shove the
meteograms down when it arrives -->
<section class="mt-6" in:fade={{ duration: 200 }}>
<div class="h-52 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-40"></div>
</section>
{/if}
{#if fetchedHourly}
<div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts
data={fetchedHourly}
{selectedDay}
units={params}
{loading}
{chartHeight}
/>
</div>
{:else}
<!-- reserve the exact chart area height before the first fetch resolves,
header row included -->
<section class="mt-8" in:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
<div class="flex items-center gap-3">
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
</div>
<!-- one placeholder per row the real table will render, so the body
reads as a loading table rather than a blank panel -->
<div class="divide-y divide-border/50">
<!-- the time + daylight row, which is taller than the variable rows -->
<div class="flex items-center gap-4 px-4" style="height: {TABLE_TIME_ROW_PX}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div>
{#each { length: enabledTableRows } as _, i (i)}
<div class="flex items-center gap-4 px-4" style="height: {tableRowPx}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div>
{/each}
</div>
</div>
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
</section>
{/if}
{/if}
</div>
<div class="relative">
{#if fetchedHourly && fetchedDaily}
<div class="day-region-summary" use:daySwap={selectedDayKey}>
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
</div>
{:else}
<!-- Same footprint as the written forecast, so it doesn't shove the
meteograms down when it arrives. The heights are measured from the
real card at each breakpoint: on phones the narrative is clamped to
five lines with a fixed toggle row, so that side is exact; from md
up the text runs free and this is the typical height. The sun/moon
grid reflows at sm, md and lg, which is why all four are needed. -->
<section class="mt-6" in:fade={{ duration: 200 }} out:skeletonOut>
<div
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
></div>
</section>
{/if}
</div>
<div class="relative">
{#if fetchedHourly}
<div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts
data={fetchedHourly}
{selectedDay}
units={params}
{loading}
{chartHeight}
/>
</div>
{:else}
<!-- reserve the exact chart area height before the first fetch resolves,
header row included -->
<section class="mt-8" in:fade={{ duration: 200 }} out:skeletonOut>
<!-- The real header wraps to two rows until the controls fit beside
the title, which happens at different widths than you would
expect because the sidebar takes its share from md up. These
min-heights follow the measured wrap points. -->
<div
class="mb-3 flex min-h-27 flex-wrap items-center justify-between gap-2 sm:min-h-16.5 md:min-h-27 lg:min-h-16.5 xl:min-h-7.5"
>
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
<div class="flex items-center gap-3">
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
</div>
</div>
<!-- Each meteogram is its plot plus a title row and axis labels, so
the reserved box needs that chrome on top of the plot height or
everything below lands ~50px per chart too high. -->
<ChartContainer
loading
chartCount={enabledChartCount || 1}
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
/>
</section>
{/if}
</div>
{#if selectedDayKey}
<NearbyCities
@@ -6,7 +6,7 @@
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
@@ -196,6 +196,7 @@
height="100px"
style="filter: url(#thin-day-icon)"
>
<title>{getWeatherDescription(dayCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode,
@@ -208,6 +209,7 @@
width="42px"
height="42px"
>
<title>{getWeatherDescription(nightCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode,
@@ -7,7 +7,7 @@
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import {
getSunshineColor,
getSunshinePercent,
@@ -276,6 +276,7 @@
<div class="icon-row flex w-full items-center justify-center">
<div class="icon-wrap">
<svg class="day-icon fill-foreground">
<title>{getWeatherDescription(dayCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode,
@@ -285,6 +286,7 @@
</svg>
</div>
<svg class="night-icon self-end fill-foreground/60">
<title>{getWeatherDescription(nightCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode,
@@ -96,6 +96,35 @@
return `M 0 ${-R} A ${R} ${R} 0 0 ${outerSweep} 0 ${R} A ${rx} ${R} 0 0 ${innerSweep} 0 ${-R} Z`;
});
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
// ─── Narrative clamp (phones only) ──────────────────────────────────────────
// The narrative runs anywhere from two to nine lines depending on the day,
// which on a phone means the whole page below it moves as soon as the data
// lands. Below md the text is clamped to five lines and gets a toggle, and
// the block reserves those five lines plus the toggle row whatever the
// length - so the card is the same height before and after the fetch, and the
// skeleton can match it exactly. From md up nothing is clamped.
let expanded = $state(false);
let textEl = $state<HTMLParagraphElement>();
let overflows = $state(false);
$effect(() => {
sentences; // re-measure when the day (and so the text) changes
const el = textEl;
if (!el || expanded) return; // measuring while open would always say "fits"
const measure = () => {
// only clamped below md, where the toggle is the only way to see the rest
const clamped = window.matchMedia('(max-width: 767px)').matches;
overflows = clamped && el.scrollHeight - el.clientHeight > 1;
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
});
</script>
<section class="mt-6" aria-label={m.summary_heading()}>
@@ -115,15 +144,35 @@
</div>
<div class="grid gap-4 px-4 py-3.5 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-6">
{#if sentences.length > 0}
<p class="text-[15px] leading-relaxed text-foreground">
{sentences.join(' ')}
</p>
{:else}
<p class="text-[15px] leading-relaxed text-muted-foreground">
{m.summary_no_data()}
</p>
{/if}
<div class="narrative">
{#if sentences.length > 0}
<p
bind:this={textEl}
class="text-[15px] leading-relaxed text-foreground"
class:clamped={!expanded}
>
{sentences.join(' ')}
</p>
{:else}
<p class="text-[15px] leading-relaxed text-muted-foreground">
{m.summary_no_data()}
</p>
{/if}
<!-- Always occupies its row on phones, even when the text fits: a
toggle that appears only sometimes is itself a layout shift. -->
<button
type="button"
class="toggle mt-1 cursor-pointer text-[13px] font-semibold text-primary hover:underline"
class:invisible={!overflows && !expanded}
aria-hidden={!overflows && !expanded}
tabindex={!overflows && !expanded ? -1 : 0}
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>
{expanded ? m.summary_read_less() : m.summary_read_more()}
</button>
</div>
<!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
<dl
@@ -218,3 +267,36 @@
</div>
</div>
</section>
<style>
/* Phones: exactly five lines, reserved whether the text is long or short, so
the card's height never depends on the forecast that lands. 15px text at
leading-relaxed (1.625) → 5 lines = 8.125em. */
.clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
line-clamp: 5;
overflow: hidden;
}
.narrative p {
min-height: 8.125em;
}
/* md and up: no clamp, no reserved height, no toggle. */
@media (min-width: 768px) {
.clamped {
display: block;
overflow: visible;
}
.narrative p {
min-height: 0;
}
.toggle {
display: none;
}
}
</style>
@@ -16,7 +16,7 @@
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import {
type FetchedDaily,
type FetchedHourly,
@@ -322,6 +322,10 @@
// positioned relative to the data columns only.
let headerColWidth = $state(0);
let tableWidth = $state(0);
// Measured so the centred NOW badge can be clamped fully inside the row:
// centred on a time near midnight it would poke past the last column and
// hand the scroller a sliver of phantom overflow.
let nowBadgeWidth = $state(0);
let nowLeftPx = $derived(
nowPercent != null && tableWidth > 0
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
@@ -425,8 +429,9 @@
});
</script>
{#snippet weatherIcon(name: string, size: number = 16)}
{#snippet weatherIcon(name: string, size: number = 16, 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}
@@ -607,14 +612,17 @@
{#if nowPercent != null}
<div
class="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-red-500/75"
style="left:{nowPercent}%"
style="left:clamp(1px, {nowPercent}%, calc(100% - 1px))"
></div>
{/if}
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
<!-- "Now" label, aligned with the sunrise/sunset labels along the
bottom; its centre is clamped so the pill never leaves the row -->
{#if isTodaySelected && nowPercent != null}
<span
bind:clientWidth={nowBadgeWidth}
class="absolute bottom-0.5 z-15 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
style="left:{nowPercent}%"
style="left:clamp({nowBadgeWidth /
2}px, {nowPercent}%, calc(100% - {nowBadgeWidth / 2}px))"
>
{m.table_now()}
</span>
@@ -678,7 +686,11 @@
!cellData[i + 1].isDaytime}
>
{#if finite(wCode)}
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
{@render weatherIcon(
getWeatherIconName(wCode, cell.isDaytime),
iconPx,
getWeatherDescription(wCode)
)}
{/if}
</td>
{/each}
@@ -899,24 +911,31 @@
</tbody>
</table>
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
<!-- Hovered-column highlight, mirroring the meteogram crosshair.
max-width caps the box at the wrapper's true (fractional) right
edge: the widths here derive from rounded clientWidth bindings, so
on the last column left+width can land a fraction of a pixel past
the edge - enough scrollable overflow for a phantom scrollbar. -->
{#if hoveredCol >= 0 && tableWidth > 0}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const colLeft = headerColWidth + hoveredCol * colWidth}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
></div>
{/if}
<!-- "Now" column highlight. The current-time line itself is painted per
cell (.now-cell) so it stays under the values and icons. -->
cell (.now-cell) so it stays under the values and icons. Same
max-width cap as the hover highlight above. -->
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
{#if nowIdx >= 0}
{@const colLeft = headerColWidth + nowIdx * colWidth}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5"
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
></div>
{/if}
{/if}
@@ -64,9 +64,13 @@
if (val) onModelChange(val);
}}
>
<!-- Fixed width from sm up (mobile stays full-width): the trigger used to hug
its content, so its size changed with every model name and differed
between pages. One constant footprint, sized for the longest label in
the catalogue; anything longer truncates. -->
<Select.Trigger
aria-label={m.model_selector_aria({ label })}
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:w-80 sm:gap-3 sm:py-2 sm:flex-none"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
@@ -9,7 +9,7 @@
import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities';
import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import type { UnitPrefs } from '$lib/stores/settings';
@@ -113,6 +113,7 @@
>
<svg class="shrink-0 fill-foreground/80" width="34px" height="34px">
{#if day}
<title>{getWeatherDescription(day.weatherCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
day.weatherCode,
+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<title></title>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e0f2fe" />
<stop offset="1" stop-color="#bae6fd" />
</linearGradient>
<linearGradient id="canopy" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fb923c" />
<stop offset="1" stop-color="#ea580c" />
</linearGradient>
<linearGradient id="drop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#38bdf8" />
<stop offset="1" stop-color="#2563eb" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#sky)" />
<!-- raindrops falling onto the umbrella -->
<path d="M12 5c2 2.9 3 4.6 3 6.2a3 3 0 0 1-6 0c0-1.6 1-3.3 3-6.2Z" fill="url(#drop)" />
<path d="M53 4c2 2.9 3 4.6 3 6.2a3 3 0 0 1-6 0c0-1.6 1-3.3 3-6.2Z" fill="url(#drop)" />
<path d="M23 2.5c1.7 2.4 2.5 3.9 2.5 5.2a2.5 2.5 0 0 1-5 0c0-1.3.8-2.8 2.5-5.2Z" fill="url(#drop)" />
<path d="M45 12c1.7 2.4 2.5 3.9 2.5 5.2a2.5 2.5 0 0 1-5 0c0-1.3.8-2.8 2.5-5.2Z" fill="url(#drop)" />
<!-- pole with curved handle -->
<path
d="M33 35v17a4.5 4.5 0 0 1-9 0"
fill="none"
stroke="#475569"
stroke-width="3.25"
stroke-linecap="round"
/>
<!-- canopy tip -->
<path d="M33 13.5v4" fill="none" stroke="#475569" stroke-width="3" stroke-linecap="round" />
<!-- canopy with scalloped edge -->
<path
d="M11 37c0-11.6 9.8-21 22-21s22 9.4 22 21c-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.4 0-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.4 0-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.3 0Z"
fill="url(#canopy)"
/>
<!-- ribs -->
<path
d="M33 16.5c-5.2 3-7.4 11-7.3 19M33 16.5c5.2 3 7.4 11 7.3 19"
fill="none"
stroke="#9a3412"
stroke-width="1.5"
opacity="0.35"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+15 -1
View File
@@ -45,12 +45,26 @@ const config = {
];
const localized = locales.flatMap((locale) => shared.map((p) => `/${locale}${p}`));
// Every per-location route, not just the week page: an unprerendered
// path is served by the SPA fallback, which the host answers with a
// 404 status. The page still works, but it costs a bogus 404 on every
// hard reload (and tells crawlers the page does not exist).
const cityRoutes = [
'/weather/week',
'/weather/compare',
'/weather/14-day',
'/weather/seasonal',
'/weather/historical'
];
try {
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
const raw = fs.readFileSync(citiesPath, 'utf-8');
const cities = JSON.parse(raw);
if (Array.isArray(cities)) {
const cityEntries = cities.map((c) => `/en/weather/week/${c}`);
const cityEntries = cities.flatMap((c) =>
cityRoutes.map((route) => `/en${route}/${c}`)
);
// Keep the default wildcard to include other routes
return ['*', ...localized, ...cityEntries];
}