19 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
60 changed files with 2304 additions and 1157 deletions
+13
View File
@@ -33,3 +33,16 @@ AGENTS.md
# Claude Code scratch: throwaway probe scripts, never committed # Claude Code scratch: throwaway probe scripts, never committed
/.scratch /.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 and resolves the location client-side. Configure the server to serve
`404.html` for unknown paths. `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) ### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
@@ -83,11 +89,12 @@ drizzli.example.com {
server { server {
server_name drizzli.example.com; server_name drizzli.example.com;
root /srv/drizzli; root /srv/drizzli;
error_page 404 /404.html;
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header Cross-Origin-Embedder-Policy "require-corp" always;
location / { 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;
} }
} }
``` ```
+41 -5
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Tage-Vorhersage", "nav_week": "Wochenvorhersage",
"nav_compare": "Modellvergleich", "nav_compare": "Modellvergleich",
"nav_14day": "14-Tage-Vorhersage", "nav_14day": "14-Tage-Vorhersage",
"nav_seasonal": "Saisonal", "nav_seasonal": "Saisonal",
@@ -44,7 +44,7 @@
"day_today": "Heute", "day_today": "Heute",
"day_tomorrow": "Morgen", "day_tomorrow": "Morgen",
"day_yesterday": "Gestern", "day_yesterday": "Gestern",
"page_week_subtitle": "7-Tage-Vorhersage", "page_week_subtitle": "Wochenvorhersage",
"page_compare_subtitle": "Modellvergleich", "page_compare_subtitle": "Modellvergleich",
"page_14day_subtitle": "14-Tage-Ensemblevorhersage", "page_14day_subtitle": "14-Tage-Ensemblevorhersage",
"page_seasonal_subtitle": "Saisonale Aussichten", "page_seasonal_subtitle": "Saisonale Aussichten",
@@ -101,7 +101,7 @@
"period_morning": "morgens", "period_morning": "morgens",
"period_afternoon": "nachmittags", "period_afternoon": "nachmittags",
"period_evening": "in den Abendstunden", "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_data_by": "Wetterdaten von",
"footer_forecasts": "Vorhersagen", "footer_forecasts": "Vorhersagen",
"footer_popular": "Beliebte Orte", "footer_popular": "Beliebte Orte",
@@ -260,6 +260,8 @@
"table_interval_aria": "Stundenintervall", "table_interval_aria": "Stundenintervall",
"table_now": "Jetzt", "table_now": "Jetzt",
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln", "interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
"page_loading": "Wird geladen…",
"page_loading_dismiss": "Schließen",
"charts_loading": "Diagramme werden geladen…", "charts_loading": "Diagramme werden geladen…",
"chart_download": "Meteogramm als PNG-Bild herunterladen", "chart_download": "Meteogramm als PNG-Bild herunterladen",
"chart_credit_viz": "Visualisierung von", "chart_credit_viz": "Visualisierung von",
@@ -267,7 +269,7 @@
"legend_show": "Legende anzeigen", "legend_show": "Legende anzeigen",
"meteograms_none_before": "Keine Meteogramme konfiguriert,", "meteograms_none_before": "Keine Meteogramme konfiguriert,",
"meteograms_none_action": "fügen Sie Variablen hinzu", "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_searching": "Wird gesucht…",
"search_favorites": "Favoriten", "search_favorites": "Favoriten",
"search_recent": "Zuletzt", "search_recent": "Zuletzt",
@@ -280,6 +282,7 @@
"model_automatic_selection": "Automatische Auswahl", "model_automatic_selection": "Automatische Auswahl",
"model_selector_aria": "Auswahl: {label}", "model_selector_aria": "Auswahl: {label}",
"compare_models_heading": "Modelle", "compare_models_heading": "Modelle",
"compare_models_choose": "Modelle für den Vergleich wählen",
"compare_variables_heading": "Stündliche Wettervariablen", "compare_variables_heading": "Stündliche Wettervariablen",
"compare_standard_preset": "Standardvergleich", "compare_standard_preset": "Standardvergleich",
"compare_weather_conditions": "Wetterlage & Gesamtbewölkung", "compare_weather_conditions": "Wetterlage & Gesamtbewölkung",
@@ -293,6 +296,7 @@
"compare_selected_models": "Ausgewählte Modelle", "compare_selected_models": "Ausgewählte Modelle",
"compare_selection_pending": "Auswahl geändert", "compare_selection_pending": "Auswahl geändert",
"compare_apply_selection": "Anwenden & Diagramme neu laden", "compare_apply_selection": "Anwenden & Diagramme neu laden",
"compare_discard_changes": "Änderungen verwerfen",
"compare_customize_models": "Modelle anpassen", "compare_customize_models": "Modelle anpassen",
"compare_edit_models": "Modelle bearbeiten", "compare_edit_models": "Modelle bearbeiten",
"compare_edit_variables": "Variablen bearbeiten", "compare_edit_variables": "Variablen bearbeiten",
@@ -300,6 +304,8 @@
"compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.", "compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.",
"compare_search_models": "Modelle suchen", "compare_search_models": "Modelle suchen",
"compare_search_variables": "Variablen 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_only_selected": "Nur ausgewählte",
"compare_select_group": "Alle auswählen", "compare_select_group": "Alle auswählen",
"compare_clear_group": "Alle abwählen", "compare_clear_group": "Alle abwählen",
@@ -382,5 +388,35 @@
"cadence_varies": "variiert", "cadence_varies": "variiert",
"model_group_automatic": "Automatisch", "model_group_automatic": "Automatisch",
"model_group_reanalysis": "ECMWF-Reanalyse", "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"
} }
+41 -5
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Day Forecast", "nav_week": "Weekly Forecast",
"nav_compare": "Model Comparison", "nav_compare": "Model Comparison",
"nav_14day": "14-Day Forecast", "nav_14day": "14-Day Forecast",
"nav_seasonal": "Seasonal", "nav_seasonal": "Seasonal",
@@ -44,7 +44,7 @@
"day_today": "Today", "day_today": "Today",
"day_tomorrow": "Tomorrow", "day_tomorrow": "Tomorrow",
"day_yesterday": "Yesterday", "day_yesterday": "Yesterday",
"page_week_subtitle": "7-day forecast", "page_week_subtitle": "Weekly forecast",
"page_compare_subtitle": "Model comparison", "page_compare_subtitle": "Model comparison",
"page_14day_subtitle": "14-day ensemble forecast", "page_14day_subtitle": "14-day ensemble forecast",
"page_seasonal_subtitle": "Seasonal outlook", "page_seasonal_subtitle": "Seasonal outlook",
@@ -101,7 +101,7 @@
"period_morning": "in the morning", "period_morning": "in the morning",
"period_afternoon": "in the afternoon", "period_afternoon": "in the afternoon",
"period_evening": "in the evening", "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_data_by": "Weather data by",
"footer_forecasts": "Forecasts", "footer_forecasts": "Forecasts",
"footer_popular": "Popular locations", "footer_popular": "Popular locations",
@@ -260,6 +260,8 @@
"table_interval_aria": "Hourly interval", "table_interval_aria": "Hourly interval",
"table_now": "Now", "table_now": "Now",
"interval_toggle": "Toggle between 1-hour and 3-hour intervals", "interval_toggle": "Toggle between 1-hour and 3-hour intervals",
"page_loading": "Loading…",
"page_loading_dismiss": "Dismiss",
"charts_loading": "Loading charts...", "charts_loading": "Loading charts...",
"chart_download": "Download meteogram as PNG image", "chart_download": "Download meteogram as PNG image",
"chart_credit_viz": "visualisation by", "chart_credit_viz": "visualisation by",
@@ -267,7 +269,7 @@
"legend_show": "Show legend", "legend_show": "Show legend",
"meteograms_none_before": "No meteograms configured,", "meteograms_none_before": "No meteograms configured,",
"meteograms_none_action": "add some variables", "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_searching": "Searching...",
"search_favorites": "Favorites", "search_favorites": "Favorites",
"search_recent": "Recent", "search_recent": "Recent",
@@ -280,6 +282,7 @@
"model_automatic_selection": "Automatic selection", "model_automatic_selection": "Automatic selection",
"model_selector_aria": "{label} selection", "model_selector_aria": "{label} selection",
"compare_models_heading": "Models", "compare_models_heading": "Models",
"compare_models_choose": "Choose which models to plot",
"compare_variables_heading": "Hourly Weather Variables", "compare_variables_heading": "Hourly Weather Variables",
"compare_standard_preset": "Standard comparison", "compare_standard_preset": "Standard comparison",
"compare_weather_conditions": "Weather conditions & cloud cover", "compare_weather_conditions": "Weather conditions & cloud cover",
@@ -293,6 +296,7 @@
"compare_selected_models": "Selected models", "compare_selected_models": "Selected models",
"compare_selection_pending": "Selection changed", "compare_selection_pending": "Selection changed",
"compare_apply_selection": "Apply & reload charts", "compare_apply_selection": "Apply & reload charts",
"compare_discard_changes": "Discard changes",
"compare_customize_models": "Customize models", "compare_customize_models": "Customize models",
"compare_edit_models": "Edit models", "compare_edit_models": "Edit models",
"compare_edit_variables": "Edit variables", "compare_edit_variables": "Edit variables",
@@ -300,6 +304,8 @@
"compare_edit_variables_description": "Search variables or select complete thematic groups.", "compare_edit_variables_description": "Search variables or select complete thematic groups.",
"compare_search_models": "Search models", "compare_search_models": "Search models",
"compare_search_variables": "Search variables", "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_only_selected": "Only selected",
"compare_select_group": "Select all", "compare_select_group": "Select all",
"compare_clear_group": "Clear all", "compare_clear_group": "Clear all",
@@ -382,5 +388,35 @@
"cadence_varies": "varies", "cadence_varies": "varies",
"model_group_automatic": "Automatic", "model_group_automatic": "Automatic",
"model_group_reanalysis": "ECMWF reanalysis", "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"
} }
+44 -8
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$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_compare": "Comparación de modelos",
"nav_14day": "Pronóstico de 14 días", "nav_14day": "Pronóstico de 14 días",
"nav_seasonal": "Estacional", "nav_seasonal": "Estacional",
@@ -44,11 +44,11 @@
"day_today": "Hoy", "day_today": "Hoy",
"day_tomorrow": "Mañana", "day_tomorrow": "Mañana",
"day_yesterday": "Ayer", "day_yesterday": "Ayer",
"page_week_subtitle": "pronóstico de 7 días", "page_week_subtitle": "Pronóstico semanal",
"page_compare_subtitle": "comparación de modelos", "page_compare_subtitle": "Comparación de modelos",
"page_14day_subtitle": "pronóstico de conjunto a 14 días", "page_14day_subtitle": "Pronóstico de conjunto a 14 días",
"page_seasonal_subtitle": "previsión estacional", "page_seasonal_subtitle": "Previsión estacional",
"page_historical_subtitle": "clima histórico", "page_historical_subtitle": "Clima histórico",
"hourly_heading": "por horas", "hourly_heading": "por horas",
"hourly_variables": "Variables", "hourly_variables": "Variables",
"meteograms_heading": "Meteogramas", "meteograms_heading": "Meteogramas",
@@ -260,6 +260,8 @@
"table_interval_aria": "Intervalo horario", "table_interval_aria": "Intervalo horario",
"table_now": "Ahora", "table_now": "Ahora",
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas", "interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
"page_loading": "Cargando…",
"page_loading_dismiss": "Descartar",
"charts_loading": "Cargando gráficos…", "charts_loading": "Cargando gráficos…",
"chart_download": "Descargar el meteograma como imagen PNG", "chart_download": "Descargar el meteograma como imagen PNG",
"chart_credit_viz": "visualización de", "chart_credit_viz": "visualización de",
@@ -267,7 +269,7 @@
"legend_show": "Mostrar leyenda", "legend_show": "Mostrar leyenda",
"meteograms_none_before": "No hay meteogramas configurados,", "meteograms_none_before": "No hay meteogramas configurados,",
"meteograms_none_action": "añade algunas variables", "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_searching": "Buscando…",
"search_favorites": "Favoritos", "search_favorites": "Favoritos",
"search_recent": "Recientes", "search_recent": "Recientes",
@@ -280,6 +282,7 @@
"model_automatic_selection": "Selección automática", "model_automatic_selection": "Selección automática",
"model_selector_aria": "Selección de {label}", "model_selector_aria": "Selección de {label}",
"compare_models_heading": "Modelos", "compare_models_heading": "Modelos",
"compare_models_choose": "Elige los modelos a comparar",
"compare_variables_heading": "Variables meteorológicas horarias", "compare_variables_heading": "Variables meteorológicas horarias",
"compare_standard_preset": "Comparación estándar", "compare_standard_preset": "Comparación estándar",
"compare_weather_conditions": "Condiciones y nubosidad", "compare_weather_conditions": "Condiciones y nubosidad",
@@ -293,6 +296,7 @@
"compare_selected_models": "Modelos seleccionados", "compare_selected_models": "Modelos seleccionados",
"compare_selection_pending": "Selección modificada", "compare_selection_pending": "Selección modificada",
"compare_apply_selection": "Aplicar y recargar gráficos", "compare_apply_selection": "Aplicar y recargar gráficos",
"compare_discard_changes": "Descartar cambios",
"compare_customize_models": "Personalizar modelos", "compare_customize_models": "Personalizar modelos",
"compare_edit_models": "Editar modelos", "compare_edit_models": "Editar modelos",
"compare_edit_variables": "Editar variables", "compare_edit_variables": "Editar variables",
@@ -300,6 +304,8 @@
"compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.", "compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.",
"compare_search_models": "Buscar modelos", "compare_search_models": "Buscar modelos",
"compare_search_variables": "Buscar variables", "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_only_selected": "Solo seleccionados",
"compare_select_group": "Seleccionar todo", "compare_select_group": "Seleccionar todo",
"compare_clear_group": "Deseleccionar todo", "compare_clear_group": "Deseleccionar todo",
@@ -382,5 +388,35 @@
"cadence_varies": "variable", "cadence_varies": "variable",
"model_group_automatic": "Automático", "model_group_automatic": "Automático",
"model_group_reanalysis": "Reanálisis del ECMWF", "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"
} }
+44 -8
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$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_compare": "Comparaison des modèles",
"nav_14day": "Prévisions à 14 jours", "nav_14day": "Prévisions à 14 jours",
"nav_seasonal": "Saisonnier", "nav_seasonal": "Saisonnier",
@@ -44,11 +44,11 @@
"day_today": "Aujourd'hui", "day_today": "Aujourd'hui",
"day_tomorrow": "Demain", "day_tomorrow": "Demain",
"day_yesterday": "Hier", "day_yesterday": "Hier",
"page_week_subtitle": "prévisions à 7 jours", "page_week_subtitle": "Prévisions hebdomadaires",
"page_compare_subtitle": "comparaison des modèles", "page_compare_subtitle": "Comparaison des modèles",
"page_14day_subtitle": "prévision d'ensemble à 14 jours", "page_14day_subtitle": "Prévision d'ensemble à 14 jours",
"page_seasonal_subtitle": "tendances saisonnières", "page_seasonal_subtitle": "Tendances saisonnières",
"page_historical_subtitle": "météo historique", "page_historical_subtitle": "Météo historique",
"hourly_heading": "par heure", "hourly_heading": "par heure",
"hourly_variables": "Variables", "hourly_variables": "Variables",
"meteograms_heading": "Météogrammes", "meteograms_heading": "Météogrammes",
@@ -260,6 +260,8 @@
"table_interval_aria": "Intervalle horaire", "table_interval_aria": "Intervalle horaire",
"table_now": "Maintenant", "table_now": "Maintenant",
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h", "interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
"page_loading": "Chargement…",
"page_loading_dismiss": "Fermer",
"charts_loading": "Chargement des graphiques…", "charts_loading": "Chargement des graphiques…",
"chart_download": "Télécharger le météogramme en PNG", "chart_download": "Télécharger le météogramme en PNG",
"chart_credit_viz": "visualisation par", "chart_credit_viz": "visualisation par",
@@ -267,7 +269,7 @@
"legend_show": "Afficher la légende", "legend_show": "Afficher la légende",
"meteograms_none_before": "Aucun météogramme configuré,", "meteograms_none_before": "Aucun météogramme configuré,",
"meteograms_none_action": "ajoutez des variables", "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_searching": "Recherche…",
"search_favorites": "Favoris", "search_favorites": "Favoris",
"search_recent": "Récents", "search_recent": "Récents",
@@ -280,6 +282,7 @@
"model_automatic_selection": "Sélection automatique", "model_automatic_selection": "Sélection automatique",
"model_selector_aria": "Sélection : {label}", "model_selector_aria": "Sélection : {label}",
"compare_models_heading": "Modèles", "compare_models_heading": "Modèles",
"compare_models_choose": "Choisir les modèles à comparer",
"compare_variables_heading": "Variables météo horaires", "compare_variables_heading": "Variables météo horaires",
"compare_standard_preset": "Comparaison standard", "compare_standard_preset": "Comparaison standard",
"compare_weather_conditions": "Conditions & couverture nuageuse", "compare_weather_conditions": "Conditions & couverture nuageuse",
@@ -293,6 +296,7 @@
"compare_selected_models": "Modèles sélectionnés", "compare_selected_models": "Modèles sélectionnés",
"compare_selection_pending": "Sélection modifiée", "compare_selection_pending": "Sélection modifiée",
"compare_apply_selection": "Appliquer et recharger les graphiques", "compare_apply_selection": "Appliquer et recharger les graphiques",
"compare_discard_changes": "Annuler les modifications",
"compare_customize_models": "Personnaliser les modèles", "compare_customize_models": "Personnaliser les modèles",
"compare_edit_models": "Modifier les modèles", "compare_edit_models": "Modifier les modèles",
"compare_edit_variables": "Modifier les variables", "compare_edit_variables": "Modifier les variables",
@@ -300,6 +304,8 @@
"compare_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques 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_models": "Rechercher des modèles",
"compare_search_variables": "Rechercher des variables", "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_only_selected": "Sélection uniquement",
"compare_select_group": "Tout sélectionner", "compare_select_group": "Tout sélectionner",
"compare_clear_group": "Tout désélectionner", "compare_clear_group": "Tout désélectionner",
@@ -382,5 +388,35 @@
"cadence_varies": "variable", "cadence_varies": "variable",
"model_group_automatic": "Automatique", "model_group_automatic": "Automatique",
"model_group_reanalysis": "Réanalyse ECMWF", "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"
} }
+44 -8
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Previsioni a 7 giorni", "nav_week": "Previsioni settimanali",
"nav_compare": "Confronto modelli", "nav_compare": "Confronto modelli",
"nav_14day": "Previsioni a 14 giorni", "nav_14day": "Previsioni a 14 giorni",
"nav_seasonal": "Stagionale", "nav_seasonal": "Stagionale",
@@ -44,11 +44,11 @@
"day_today": "Oggi", "day_today": "Oggi",
"day_tomorrow": "Domani", "day_tomorrow": "Domani",
"day_yesterday": "Ieri", "day_yesterday": "Ieri",
"page_week_subtitle": "previsioni a 7 giorni", "page_week_subtitle": "Previsioni settimanali",
"page_compare_subtitle": "confronto modelli", "page_compare_subtitle": "Confronto modelli",
"page_14day_subtitle": "previsione d'insieme a 14 giorni", "page_14day_subtitle": "Previsione d'insieme a 14 giorni",
"page_seasonal_subtitle": "tendenze stagionali", "page_seasonal_subtitle": "Tendenze stagionali",
"page_historical_subtitle": "meteo storico", "page_historical_subtitle": "Meteo storico",
"hourly_heading": "orario", "hourly_heading": "orario",
"hourly_variables": "Variabili", "hourly_variables": "Variabili",
"meteograms_heading": "Meteogrammi", "meteograms_heading": "Meteogrammi",
@@ -260,6 +260,8 @@
"table_interval_aria": "Intervallo orario", "table_interval_aria": "Intervallo orario",
"table_now": "Ora", "table_now": "Ora",
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore", "interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
"page_loading": "Caricamento…",
"page_loading_dismiss": "Chiudi",
"charts_loading": "Caricamento dei grafici…", "charts_loading": "Caricamento dei grafici…",
"chart_download": "Scarica il meteogramma come immagine PNG", "chart_download": "Scarica il meteogramma come immagine PNG",
"chart_credit_viz": "visualizzazione di", "chart_credit_viz": "visualizzazione di",
@@ -267,7 +269,7 @@
"legend_show": "Mostra legenda", "legend_show": "Mostra legenda",
"meteograms_none_before": "Nessun meteogramma configurato,", "meteograms_none_before": "Nessun meteogramma configurato,",
"meteograms_none_action": "aggiungi qualche variabile", "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_searching": "Ricerca in corso…",
"search_favorites": "Preferiti", "search_favorites": "Preferiti",
"search_recent": "Recenti", "search_recent": "Recenti",
@@ -280,6 +282,7 @@
"model_automatic_selection": "Selezione automatica", "model_automatic_selection": "Selezione automatica",
"model_selector_aria": "Selezione di {label}", "model_selector_aria": "Selezione di {label}",
"compare_models_heading": "Modelli", "compare_models_heading": "Modelli",
"compare_models_choose": "Scegli i modelli da confrontare",
"compare_variables_heading": "Variabili meteo orarie", "compare_variables_heading": "Variabili meteo orarie",
"compare_standard_preset": "Confronto standard", "compare_standard_preset": "Confronto standard",
"compare_weather_conditions": "Condizioni e copertura nuvolosa", "compare_weather_conditions": "Condizioni e copertura nuvolosa",
@@ -293,6 +296,7 @@
"compare_selected_models": "Modelli selezionati", "compare_selected_models": "Modelli selezionati",
"compare_selection_pending": "Selezione modificata", "compare_selection_pending": "Selezione modificata",
"compare_apply_selection": "Applica e ricarica i grafici", "compare_apply_selection": "Applica e ricarica i grafici",
"compare_discard_changes": "Annulla modifiche",
"compare_customize_models": "Personalizza modelli", "compare_customize_models": "Personalizza modelli",
"compare_edit_models": "Modifica modelli", "compare_edit_models": "Modifica modelli",
"compare_edit_variables": "Modifica variabili", "compare_edit_variables": "Modifica variabili",
@@ -300,6 +304,8 @@
"compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.", "compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.",
"compare_search_models": "Cerca modelli", "compare_search_models": "Cerca modelli",
"compare_search_variables": "Cerca variabili", "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_only_selected": "Solo selezionati",
"compare_select_group": "Seleziona tutto", "compare_select_group": "Seleziona tutto",
"compare_clear_group": "Deseleziona tutto", "compare_clear_group": "Deseleziona tutto",
@@ -382,5 +388,35 @@
"cadence_varies": "variabile", "cadence_varies": "variabile",
"model_group_automatic": "Automatico", "model_group_automatic": "Automatico",
"model_group_reanalysis": "Rianalisi ECMWF", "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" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -812,9 +809,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -832,9 +826,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -852,9 +843,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -872,9 +860,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -892,9 +877,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1240,9 +1222,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1260,9 +1239,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1280,9 +1256,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1300,9 +1273,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3124,9 +3094,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3148,9 +3115,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3172,9 +3136,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3196,9 +3157,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
+5
View File
@@ -3,6 +3,11 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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> <script>
// apply the persisted theme before first paint to avoid a flash // apply the persisted theme before first paint to avoid a flash
try { try {
+13 -2
View File
@@ -209,6 +209,8 @@
yTicks?: number[]; yTicks?: number[];
/** Custom left-axis tick formatter. */ /** Custom left-axis tick formatter. */
yTickFormat?: (value: number) => string; 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) */ /** Force the derived left axis to include zero (default true) */
zeroBaseLeft?: boolean; zeroBaseLeft?: boolean;
/** Fixed right-axis minimum (default 0) */ /** Fixed right-axis minimum (default 0) */
@@ -260,6 +262,7 @@
yMax, yMax,
yTicks, yTicks,
yTickFormat, yTickFormat,
compactYAxis = false,
yPadTop, yPadTop,
yPadBottom, yPadBottom,
zeroBaseLeft = true, zeroBaseLeft = true,
@@ -497,7 +500,7 @@
let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows)); let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows));
// tighter top/bottom gutters on mobile so charts don't waste vertical space // tighter top/bottom gutters on mobile so charts don't waste vertical space
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H); const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH); let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 22 : 28) + iconRows * iconRowH);
let agreementStripOffset = $derived( let agreementStripOffset = $derived(
agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0 agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0
); );
@@ -1087,7 +1090,13 @@
ctx.lineTo(plotRight, y); ctx.lineTo(plotRight, y);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
ctx.fillText(yTickFormat?.(v) ?? 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 // Right axis labels in the gutter (desktop only). On mobile there is no
@@ -1543,6 +1552,7 @@
ctx.strokeStyle = withAlpha(gridColor, 0.8); ctx.strokeStyle = withAlpha(gridColor, 0.8);
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1); ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1);
if (!compactYAxis) {
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
ctx.font = '600 9px system-ui, sans-serif'; ctx.font = '600 9px system-ui, sans-serif';
ctx.textAlign = 'right'; ctx.textAlign = 'right';
@@ -1554,6 +1564,7 @@
Math.max(24, padLeft - 10) Math.max(24, padLeft - 10)
); );
} }
}
// Axis unit labels // Axis unit labels
ctx.font = font; ctx.font = font;
@@ -108,10 +108,9 @@ export async function downloadChartsPng(
} }
if (row.length > 0) legendRows.push(row); if (row.length > 0) legendRows.push(row);
} }
const headerHeight = const titleHeight = options.title ? 34 * dpr : 0;
(options.title ? 34 * dpr : 0) + const legendHeight = legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0;
(legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0); const totalHeight = titleHeight + canvases.reduce((sum, c) => sum + c.height, 0) + legendHeight;
const totalHeight = headerHeight + canvases.reduce((sum, c) => sum + c.height, 0);
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = maxWidth; canvas.width = maxWidth;
@@ -134,7 +133,12 @@ export async function downloadChartsPng(
ctx.fillText(options.title, padding, 17 * dpr); ctx.fillText(options.title, padding, 17 * dpr);
y += 34 * dpr; y += 34 * dpr;
} }
for (const c of canvases) {
ctx.drawImage(c, 0, y);
y += c.height;
}
if (legendRows.length > 0) { if (legendRows.length > 0) {
y += 8 * dpr;
ctx.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`; ctx.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
for (const row of legendRows) { for (const row of legendRows) {
@@ -165,11 +169,6 @@ export async function downloadChartsPng(
} }
y += rowHeight; y += rowHeight;
} }
y += 8 * dpr;
}
for (const c of canvases) {
ctx.drawImage(c, 0, y);
y += c.height;
} }
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`); triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
+26 -9
View File
@@ -14,7 +14,8 @@
import { href, routePath } from '$lib/i18n'; import { href, routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages'; 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 SettingsMenu from './settings-menu.svelte';
import ThemeIcon from './theme-icon.svelte'; import ThemeIcon from './theme-icon.svelte';
@@ -41,6 +42,21 @@
if (flagEl && !flagEl.src.endsWith(src)) flagEl.src = src; 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 themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, () => string> = { const themeTitles: Record<Theme, () => string> = {
system: m.theme_follow_system, system: m.theme_follow_system,
@@ -99,8 +115,10 @@
<!-- Current location display --> <!-- Current location display -->
{#if location} {#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 <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 <img
bind:this={flagEl} bind:this={flagEl}
@@ -109,13 +127,10 @@
alt={location.country} alt={location.country}
/> />
<!-- full location (desktop); the page hero carries it on smaller screens --> <!-- 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} {location.name}
{#if location.admin1 || location.country} {#if locationDetail}
<span class="font-normal text-muted-foreground"> <span class="font-normal text-muted-foreground">· {locationDetail}</span>
· {#if location.admin1}{location.admin1},&nbsp;
{/if}{location.country ?? ''}
</span>
{/if} {/if}
</span> </span>
</div> </div>
@@ -144,8 +159,10 @@
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`) <!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
so SupporterBadge still verifies the key on every viewport. --> so SupporterBadge still verifies the key on every viewport. -->
<div class="hidden items-center gap-3 md:flex"> <div class="hidden items-center gap-3 md:flex">
<!-- Supporter status / unlock --> <!-- Supporter status / unlock (hidden while supporter features are parked) -->
{#if SUPPORTER_ENABLED}
<SupporterBadge /> <SupporterBadge />
{/if}
<!-- Language: the locale lives in the URL, so this is a set of links --> <!-- Language: the locale lives in the URL, so this is a set of links -->
<LanguageSelector /> <LanguageSelector />
@@ -6,9 +6,10 @@
import UnitOptions from '$lib/components/unit-options.svelte'; import UnitOptions from '$lib/components/unit-options.svelte';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import SupporterIcon from '$lib/paywall/SupporterIcon.svelte'; import SupporterIcon from '$lib/supporter/SupporterIcon.svelte';
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte'; import UnlockDialog from '$lib/supporter/UnlockDialog.svelte';
import { isSupporter } from '$lib/paywall/supporter'; import { SUPPORTER_ENABLED } from '$lib/supporter/config';
import { isSupporter } from '$lib/supporter/store';
import ThemeIcon from './theme-icon.svelte'; import ThemeIcon from './theme-icon.svelte';
@@ -76,6 +77,8 @@
<LanguageOptions onSelect={() => (open = false)} /> <LanguageOptions onSelect={() => (open = false)} />
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
{#if SUPPORTER_ENABLED}
<div class="border-t border-border/70 pt-3"> <div class="border-t border-border/70 pt-3">
<button <button
type="button" type="button"
@@ -87,7 +90,9 @@
> >
<SupporterIcon <SupporterIcon
filled={$isSupporter} filled={$isSupporter}
class="h-4.5 w-4.5 shrink-0 {$isSupporter ? 'text-amber-500' : 'text-muted-foreground'}" 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="min-w-0 flex-1">
<span class="block text-[13px] font-semibold"> <span class="block text-[13px] font-semibold">
@@ -99,6 +104,7 @@
</span> </span>
</button> </button>
</div> </div>
{/if}
</div> </div>
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>
+22
View File
@@ -10,6 +10,18 @@ import { writable } from 'svelte/store';
*/ */
export const pageContentReady = writable(true); 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. * 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); 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 * 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). * ever sets the flag - clearing it is the layout's job (see above).
@@ -5,7 +5,7 @@
import SupporterIcon from './SupporterIcon.svelte'; import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte'; import UnlockDialog from './UnlockDialog.svelte';
import { isSupporter, refreshSupporter } from './supporter'; import { isSupporter, refreshSupporter } from './store';
let open = $state(false); let open = $state(false);
@@ -6,7 +6,7 @@
import SupporterIcon from './SupporterIcon.svelte'; import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte'; import UnlockDialog from './UnlockDialog.svelte';
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config'; import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
import { isSupporter, refreshSupporter, supporterState } from './supporter'; import { isSupporter, refreshSupporter, supporterState } from './store';
interface Props { interface Props {
/** Short feature name shown in the locked panel headline. */ /** 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 * 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 * 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. * outlined as an invitation before that.
*/ */
interface Props { interface Props {
@@ -9,13 +9,7 @@
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import { SIGNUP_URL, getSupporterPrice } from './config'; import { SIGNUP_URL, getSupporterPrice } from './config';
import { import { clearLicense, isSupporter, storedLicenseKey, supporterState, verifyKey } from './store';
clearLicense,
isSupporter,
storedLicenseKey,
supporterState,
verifyKey
} from './supporter';
interface Props { interface Props {
open?: boolean; open?: boolean;
@@ -1,29 +1,40 @@
import * as m from '$lib/paraglide/messages'; 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 * 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 * 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 * build at your deployment with the `VITE_SUPPORTER_*` env vars (e.g. in a
* `.env` file), otherwise the sensible drizz.li defaults are used. * `.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 env = import.meta.env as Record<string, string | undefined>;
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, ''); 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). */ /** Base URL of the self-hosted verify API (drizzli-paywall). */
export const PAYWALL_API_BASE = stripTrailingSlash( export const SUPPORTER_API_BASE = stripTrailingSlash(
env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li' 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). */ /** Where prospective supporters go to sign up (the verify API's signup form). */
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`; export const SIGNUP_URL =
env.VITE_SUPPORTER_SIGNUP_URL ?? env.VITE_PAYWALL_SIGNUP_URL ?? `${SUPPORTER_API_BASE}/`;
// ─── Location-based pricing ────────────────────────────────────────────────── // ─── Location-based pricing ──────────────────────────────────────────────────
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked // 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. // is timezone/locale based (no network geo lookup) and guarded for SSR.
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' }; const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
@@ -54,7 +65,7 @@ export function detectCurrency(): SupporterCurrency {
return 'EUR'; 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 { export function getSupporterPrice(): string {
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing // VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
// deployment's .env keeps working. // deployment's .env keeps working.
@@ -8,7 +8,7 @@
* *
* This gate is a convenience/honor-system gate: the frontend is open source and * 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 * 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'; 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 * 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. */ /** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
export const storedLicenseKey = persisted<string>('license_key', ''); 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 * 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 * wins; otherwise we fall back to the cached result (so a reload or a brief
* network blip doesn't lock a paying user out). * 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( export const isSupporter = derived(
[supporterState, storedSupporterCache], [supporterState, storedSupporterCache],
([$state, $cache]): boolean => { ([$state, $cache]): boolean => {
if (!SUPPORTER_ENABLED) return true;
if ($state.status === 'valid') return true; if ($state.status === 'valid') return true;
if ($state.status === 'invalid') return false; if ($state.status === 'invalid') return false;
return !!($cache && $cache.valid && notExpired($cache.expires)); 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. * subscription can still show a "renew" state) but the cache is marked invalid.
*/ */
export async function verifyKey(key: string): Promise<VerifyResult> { 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(); const trimmed = key.trim();
if (!trimmed) { if (!trimmed) {
supporterState.set({ status: 'invalid' }); supporterState.set({ status: 'invalid' });
@@ -106,7 +114,7 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
supporterState.set({ status: 'checking' }); supporterState.set({ status: 'checking' });
try { 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' } headers: { accept: 'application/json' }
}); });
const data = (await res.json()) as VerifyResult; 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. */ /** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
export async function refreshSupporter(): Promise<void> { export async function refreshSupporter(): Promise<void> {
if (!SUPPORTER_ENABLED) return;
const key = get(storedLicenseKey); const key = get(storedLicenseKey);
if (!key) { if (!key) {
supporterState.set({ status: 'idle' }); supporterState.set({ status: 'idle' });
+44 -10
View File
@@ -11,12 +11,40 @@ import type { Locale as DateFnsLocale } from 'date-fns';
// locale the messages do. // locale the messages do.
const DATE_LOCALES: Record<string, DateFnsLocale> = { en: enGB, de, es, fr, it }; 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. * 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. * 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 { 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 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. * Important for comparing weather forecast days against a selected date.
*/ */
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean { export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
const z1 = toZonedTime(date1, timeZone); const d1 = plainDate(date1);
const z2 = toZonedTime(date2, timeZone); const d2 = plainDate(date2);
return isSameDayDateFns(z1, z2); 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 { 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. * or a formatted date string, all relative to the target timezone.
*/ */
export function getRelativeDayLabel(date: Date, timeZone: string): string { export function getRelativeDayLabel(date: Date, timeZone: string): string {
const now = new Date(); const d = plainDate(date);
const zonedDate = toZonedTime(date, timeZone); if (!d || !timeZone) return '';
const zonedNow = toZonedTime(now, timeZone);
const zonedDate = toZonedTime(d, timeZone);
const zonedNow = toZonedTime(new Date(), timeZone);
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today(); 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); yesterday.setDate(yesterday.getDate() - 1);
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday(); if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
return formatZoned(date, timeZone, 'EEE d MMM'); return formatZoned(d, timeZone, 'EEE d MMM');
} }
/** /**
+47 -11
View File
@@ -1,7 +1,12 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
const prefersReducedMotion = () => import {
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; canStartViewTransition,
prefersReducedMotion,
skipActiveViewTransition,
startViewTransition,
supportsViewTransitions
} from './view-transition';
/** /**
* Runs a day change inside a view transition, so the outgoing day is still on * 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 * `.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 * header, the page chrome - is pinned by the `day-switch` class so it stays
* completely still. * 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> { export async function runDayTransition(update: () => void): Promise<void> {
if (prefersReducedMotion() || !document.startViewTransition) { if (!canStartViewTransition()) {
update(); update();
return; return;
} }
const token = ++dayTransitionToken;
const root = document.documentElement; 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 { try {
// Svelte applies the change on the next tick; the transition has to wait // Svelte applies the change on the next tick; the transition has to wait
// for that before it snapshots the new state. // for that before it snapshots the new state. `day-switch` scopes which
await document.startViewTransition(async () => { // regions take part (see routes/layout.css) and is cleared when it ends.
await startViewTransition(
async () => {
update(); update();
await tick(); await tick();
}).finished; },
} catch { { rootClass: 'day-switch' }
/* a superseded transition is fine - the DOM is already up to date */ );
} finally { } 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 = () => { const play = () => {
// view transitions handle it properly where they exist // 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 }], { node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
duration: 460, duration: 460,
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' 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({ export async function resolveLocationFromRoute({
urlLocation, urlLocation,
routePrefix, routePrefix,
@@ -86,6 +96,11 @@ export async function resolveLocationFromRoute({
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2])); 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 urlLocationName: string;
let urlLocationId: string | undefined; let urlLocationId: string | undefined;
@@ -124,6 +139,15 @@ export async function resolveLocationFromRoute({
location = candidate; 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 // trailingSlash is 'always' (see routes/+layout.ts), so the router serves
// every path with a trailing slash. Match that here or the equality check // 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 // never holds and the redirect loops forever. The comparison also has to
+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 * history entry or re-runs a load - the back button still means "the page
* before", not "the previous day I clicked". * before", not "the previous day I clicked".
* *
* Callers must pass the URL *untracked* (`get(page).url`, not `$page.url`). * The base is `location`, deliberately not `page.url`. Shallow routing does not
* Reading it reactively inside the same effect that writes it creates a loop: * republish the URL: `replaceState` writes the history entry (and files the
* replaceState publishes a new URL, the effect re-runs, writes again - which * *previous* `page.url` in it, so a popstate can restore it) but leaves
* Svelte eventually kills with `effect_update_depth_exceeded`, hanging the page. * `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 { browser } from '$app/environment';
import { replaceState } from '$app/navigation'; 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; 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)) { for (const [key, value] of Object.entries(updates)) {
if (value == null || value === '') next.searchParams.delete(key); if (value == null || value === '') next.searchParams.delete(key);
else next.searchParams.set(key, value); else next.searchParams.set(key, value);
} }
if (next.href === url.href) return; if (next.href === current.href) return;
try { try {
replaceState(next, {}); replaceState(next, {});
} catch { } 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. */ /** Reads a comma-separated list, dropping empties. */
export function readList(url: URL, key: string): string[] | null { export function readList(url: URL, key: string): string[] | null {
const raw = url.searchParams.get(key); 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);
}
});
}
+224 -59
View File
@@ -6,16 +6,26 @@
import { afterNavigate, onNavigate } from '$app/navigation'; import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores'; 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 { storedTheme } from '$lib/stores/settings';
import {
canStartViewTransition,
startViewTransition,
supportsViewTransitions
} from '$lib/utils/view-transition';
import Footer from '$lib/components/navigation/footer.svelte'; import Footer from '$lib/components/navigation/footer.svelte';
import Header from '$lib/components/navigation/header.svelte'; import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte'; import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
import favicon from '$lib/assets/favicon.svg';
import { routePath } from '$lib/i18n'; import { routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import './layout.css'; import './layout.css';
@@ -31,7 +41,9 @@
const apply = () => { const apply = () => {
const root = document.documentElement; const root = document.documentElement;
const dark = theme === 'dark' || (theme === 'system' && mq.matches); 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 // The very first application is just painting the stored theme - only
// an actual switch afterwards is worth cross-fading. // an actual switch afterwards is worth cross-fading.
@@ -42,9 +54,17 @@
return; return;
} }
if (document.startViewTransition) { if (canStartViewTransition() && !onMapsPage()) {
// one cross-fade of the whole document; component transitions untouched // 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; return;
} }
@@ -63,25 +83,91 @@
// ── Page cross-fade ─────────────────────────────────────────────────────── // ── Page cross-fade ───────────────────────────────────────────────────────
// A real cross-fade needs the outgoing page still on screen while the // 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 // 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. // held open for a short grace period, waiting for the new page to report that
// The browser keeps showing the old snapshot for that whole time (rather than // its data has landed. A cached or fast response lands inside that window and
// flashing a skeleton), then fades it into the finished page. // 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 // Past the grace period the wait gives up and the loading overlay takes over:
// screen instead of freezing the UI on a slow network. // holding the old page frozen any longer looks like a dead click, and the
const READY_CEILING_MS = 2200; // overlay is the honest answer - something is happening, it just isn't here
const FADE_OUT_MS = 170; // 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); // The map is a cross-origin iframe, and a browser does not paint one into a
let revealTimer = 0; // 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 = () => let loadingOverlay = $state(false);
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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 function showOverlay(animate: boolean): void {
// is what makes the wait reliable: the layout clears the ready flag before overlayFadesIn = animate;
// the swap rather than trusting the incoming page to have done it. loadingOverlay = true;
const DATA_ROUTES = new Set([ 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/week/[location]',
'/weather/14-day/[location]', '/weather/14-day/[location]',
'/weather/compare/[location]', '/weather/compare/[location]',
@@ -89,25 +175,67 @@
'/weather/historical/[location]' '/weather/historical/[location]'
]); ]);
/** Resolves once the freshly mounted page has its data, or at the ceiling. */ /**
async function waitForContent(): Promise<void> { * Resolves once the freshly mounted page has its data, or once the grace
const deadline = Date.now() + READY_CEILING_MS; * 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) { 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 // one more frame so the page paints its data before the snapshot is taken
await tick(); 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 ?? ''); onNavigate(async (navigation) => {
if (loadsData) markPageLoading(); 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();
// `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();
}
if (typeof document.startViewTransition === 'function') {
return new Promise<void>((swap) => { return new Promise<void>((swap) => {
document.startViewTransition(async () => { void startViewTransition(
async () => {
// hand control back so SvelteKit swaps the DOM underneath the // hand control back so SvelteKit swaps the DOM underneath the
// frozen snapshot of the old page // frozen snapshot of the old page
swap(); swap();
@@ -115,40 +243,28 @@
// rejects this promise; that is not an error worth surfacing, // rejects this promise; that is not an error worth surfacing,
// and leaving it unhandled shows up as "navigation aborted". // and leaving it unhandled shows up as "navigation aborted".
await navigation.complete.catch(() => {}); await navigation.complete.catch(() => {});
if (loadsData) await waitForContent(); if (pending) await waitForContent(underTransition);
},
// pins the chrome that is the same on both sides (routes/layout.css)
{ rootClass: 'page-switch', enabled: underTransition }
);
}); });
}); });
}
// 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));
});
let mainEl = $state<HTMLElement | null>(null); let mainEl = $state<HTMLElement | null>(null);
afterNavigate((navigation) => { 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 // 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. // handling never touches it and a new page would open half way down.
// Back/forward and in-page anchors keep their position. // Back/forward and in-page anchors keep their position.
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) { if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
mainEl?.scrollTo({ top: 0 }); 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 // the maps page embeds a full-bleed map: no padding, no scrolling
@@ -171,14 +287,66 @@
</script> </script>
<svelte:head> <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 charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head> </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"> <div class="flex h-screen overflow-hidden bg-background text-foreground">
<!-- Desktop sidebar --> <!-- 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} /> <WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
</div> </div>
@@ -221,10 +389,7 @@
{:else} {:else}
<!-- cap the content width on very large screens; the footer below <!-- cap the content width on very large screens; the footer below
gives the page its ending, so only modest bottom room is needed --> gives the page its ending, so only modest bottom room is needed -->
<div <div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
class:page-fade-hidden={!contentVisible}
>
{@render children()} {@render children()}
</div> </div>
<!-- full-bleed footer inside the scroll area (its own inner max-w), <!-- 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 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 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 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>
<p> <p>
Beiträge laufen über eine einfache Banküberweisung - kein Zahlungsdienstleister, keine Beiträge laufen über eine einfache Banküberweisung - kein Zahlungsdienstleister, keine
+2 -2
View File
@@ -25,8 +25,8 @@
<p> <p>
Drizz.li is free to use. If you find it useful, you can support it with a small monthly 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 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 thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, the
new supporter features as they land. seasonal outlook for the months ahead, and new supporter features as they land.
</p> </p>
<p> <p>
Contributions are handled by a simple bank transfer - no payment processor, no stored card 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 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 (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 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>
<p> <p>
Las aportaciones se gestionan mediante una simple transferencia bancaria: sin pasarela de pago y 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 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 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 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>
<p> <p>
Les contributions passent par un simple virement bancaire : aucun prestataire de paiement, 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 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 € 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 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>
<p> <p>
I contributi avvengono con un semplice bonifico bancario: nessun gestore di pagamenti, nessun 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; @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 ──────────────────────────────────────────────────────── /* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part 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` in the cross-fade - and nothing else is captured at all. A captured
class cancels the root animation, so the strip, header and page chrome do element is neither painted nor hit-testable in the live page for the
not so much as flicker while the table, summary and charts swap over. */ 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 { :root.day-switch .day-region-table {
view-transition-name: day-table; view-transition-name: day-table;
} }
@@ -165,26 +157,87 @@
view-transition-name: day-charts; view-transition-name: day-charts;
} }
/* The sticky day strip never changes during a day switch, but it has to be /* Region snapshots paint in a viewport-fixed layer above the live page, at
captured too: view-transition snapshots all paint in one layer above the their full layout size - including the part of the table normally
page, so without its own group (and a higher z-index in that layer) the scrolled up behind the sticky strip and the topbar. The strip used to be
fading table and charts would slide over the top of it. */ captured purely to stay on top of that, but a captured strip is dead to
:root.day-switch .daystrip { input; instead the whole overlay is clipped at the strip bar's bottom
view-transition-name: daystrip; edge (measured per switch by runDayTransition), so the snapshots stay
} out of the chrome and the chrome stays live. */
::view-transition-group(daystrip) { :root.day-switch::view-transition {
z-index: 20; 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 /* Page swaps capture the topbar and sidebar: a region that is identical on
position, including the part normally scrolled up behind the chrome. */ both sides should not be animated at all, so they are pinned here and
:root.day-switch .topbar { swapped outright below. */
:root.page-switch .topbar {
view-transition-name: topbar; view-transition-name: topbar;
} }
::view-transition-group(topbar) { ::view-transition-group(topbar) {
z-index: 30; 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-old(day-table),
::view-transition-new(day-table), ::view-transition-new(day-table),
::view-transition-old(day-summary), ::view-transition-old(day-summary),
@@ -193,32 +246,20 @@
::view-transition-new(day-charts) { ::view-transition-new(day-charts) {
animation-duration: 420ms; animation-duration: 420ms;
animation-timing-function: ease; animation-timing-function: ease;
mix-blend-mode: plus-lighter;
} }
:root.day-switch::view-transition-old(root), /* Pinned chrome swaps outright: identical on both sides, so any animation
:root.day-switch::view-transition-new(root) { would only risk a flicker. */
::view-transition-old(topbar),
::view-transition-old(sidebar) {
animation: none; animation: none;
opacity: 0;
} }
::view-transition-new(topbar),
@media (prefers-reduced-motion: reduce) { ::view-transition-new(sidebar) {
.day-region-table, animation: none;
.day-region-summary, opacity: 1;
.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;
} }
/* Fallback for browsers without view transitions: fade the colours only. */ /* Fallback for browsers without view transitions: fade the colours only. */
+3 -2
View File
@@ -15,8 +15,9 @@
<h2>1. Was Sie erhalten</h2> <h2>1. Was Sie erhalten</h2>
<p> <p>
Ein Unterstützerbeitrag schaltet die Extras für den bezahlten Zeitraum frei: derzeit 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 historisches Wetter mit Vergleich zu den Klimanormalen und die saisonalen Aussichten für die
erscheinen. Die kostenlosen Teile von Drizz.li bleiben für alle frei. kommenden Monate sowie neue Funktionen, sobald sie erscheinen. Die kostenlosen Teile von
Drizz.li bleiben für alle frei.
</p> </p>
<h2>2. Ablauf</h2> <h2>2. Ablauf</h2>
+2 -2
View File
@@ -14,8 +14,8 @@
<h2>1. What you get</h2> <h2>1. What you get</h2>
<p> <p>
A supporter contribution unlocks the supporter extras for the paid period: currently historical 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 weather with climate-normal comparisons and the seasonal outlook for the months ahead, plus new
parts of Drizz.li stay free for everyone. supporter features as they land. The free parts of Drizz.li stay free for everyone.
</p> </p>
<h2>2. How it works</h2> <h2>2. How it works</h2>
+3 -2
View File
@@ -17,8 +17,9 @@
<h2>1. Qué obtienes</h2> <h2>1. Qué obtienes</h2>
<p> <p>
Una aportación desbloquea los extras durante el periodo pagado: actualmente el clima histórico 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 con comparación frente a las normales climáticas y la perspectiva estacional de los próximos
llegando. Las partes gratuitas de Drizz.li siguen siendo gratuitas para todos. meses, además de las nuevas funciones que vayan llegando. Las partes gratuitas de Drizz.li
siguen siendo gratuitas para todos.
</p> </p>
<h2>2. Cómo funciona</h2> <h2>2. Cómo funciona</h2>
+2 -2
View File
@@ -14,8 +14,8 @@
<h2>1. Ce que vous obtenez</h2> <h2>1. Ce que vous obtenez</h2>
<p> <p>
Une contribution débloque les bonus pour la période payée : actuellement la météo historique 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 avec comparaison aux normales climatiques et l'aperçu saisonnier des mois à venir, ainsi que les
parties gratuites de Drizz.li le restent pour tout le monde. nouvelles fonctions à venir. Les parties gratuites de Drizz.li le restent pour tout le monde.
</p> </p>
<h2>2. Fonctionnement</h2> <h2>2. Fonctionnement</h2>
+3 -2
View File
@@ -14,8 +14,9 @@
<h2>1. Cosa ottieni</h2> <h2>1. Cosa ottieni</h2>
<p> <p>
Un contributo sblocca gli extra per il periodo pagato: attualmente il meteo storico con 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 confronto rispetto alle normali climatiche e le prospettive stagionali per i mesi a venire,
parti gratuite di Drizz.li restano gratuite per tutti. oltre alle nuove funzioni man mano che arrivano. Le parti gratuite di Drizz.li restano gratuite
per tutti.
</p> </p>
<h2>2. Come funziona</h2> <h2>2. Come funziona</h2>
+19 -6
View File
@@ -42,6 +42,21 @@
let subtitle = $derived( let subtitle = $derived(
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null 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> </script>
{#if subtitle && location} {#if subtitle && location}
@@ -55,13 +70,11 @@
<div class="min-w-0"> <div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl"> <h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name} {location.name}
<span class="font-medium text-muted-foreground">· {subtitle}</span>
</h1> </h1>
<p class="truncate text-sm text-muted-foreground"> {#if region}
<span class="lg:hidden" <p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
>{#if location.admin1}{location.admin1}, {/if}
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>{subtitle}
</p>
</div> </div>
</div> </div>
@@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { reportPageReady } from '$lib/stores/page-transition.svelte'; import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings'; 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 { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
@@ -44,7 +46,7 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page // the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null); reportPageReady(() => fetchedData != null || loadError != null);
useHeroActions(heroActions); useHeroActions(heroActions);
@@ -56,10 +58,12 @@
storedLocation.set(data.location); storedLocation.set(data.location);
}); });
const DEFAULT_MODEL = 'ncep_gefs_seamless';
let params = $state({ let params = $state({
...defaultParameters, ...defaultParameters,
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'], 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 // units live in a persisted store; mirror them into params so a change
@@ -94,7 +98,7 @@
$effect(() => { $effect(() => {
const model = params.models?.[0]; const model = params.models?.[0];
if (!mounted || !model) return; if (!mounted || !model) return;
syncSearchParams(get(page).url, { model }); syncSearchParams({ model: unlessDefault(model, DEFAULT_MODEL) });
}); });
// components persist across refetches; entries are null while unmounted // 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) --> <!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
{#snippet heroActions()} {#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 <ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'} selectedModel={params.models?.[0] ?? DEFAULT_MODEL}
groups={ensembleModelGroups} groups={ensembleModelGroups}
label={m.model_ensemble()} label={m.model_ensemble()}
onModelChange={(model) => { onModelChange={(model) => {
@@ -340,6 +344,8 @@
</div> </div>
{/if} {/if}
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<div class="relative">
{#if fetchedData} {#if fetchedData}
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within <!-- full-bleed graphs until lg / contained card on lg+; titles stay within
the page margins (padded), the graphs bleed to the edges --> the page margins (padded), the graphs bleed to the edges -->
@@ -374,8 +380,16 @@
</div> </div>
{:else} {:else}
<!-- reserve the chart area height before data arrives (no layout shift) --> <!-- reserve the chart area height before data arrives (no layout shift) -->
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} /> <div in:fade={{ duration: 200 }} out:skeletonOut>
<ChartContainer
loading
chartCount={params.hourly?.length || 1}
chartHeight={340}
bleed={false}
/>
</div>
{/if} {/if}
</div>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── --> <!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
+200 -243
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount, tick, untrack } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -9,16 +9,15 @@
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings'; import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date'; import { formatZoned } from '$lib/utils/date';
import { readList, syncSearchParams } from '$lib/utils/url-state'; import { skeletonOut } from '$lib/utils/skeleton-fade';
import { listUnlessDefault, readList, syncSearchParams } from '$lib/utils/url-state';
import { import {
ChartContainer, ChartContainer,
ChartToolbar, ChartToolbar,
type ExportableChart, type ExportLegendItem,
type ExportLegendItem type ExportableChart
} from '$lib/components/charts'; } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { import {
CHART_COLORS, CHART_COLORS,
@@ -39,7 +38,7 @@
import { useHeroActions } from '../../hero.svelte'; import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options'; import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options';
import ComparisonSelectionDrawer from './ComparisonSelectionDrawer.svelte'; import ComparisonSelectionPanel from './ComparisonSelectionPanel.svelte';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import { import {
cardinalDirection, cardinalDirection,
@@ -58,8 +57,14 @@
const MODEL_IDS = new Set(modelOptions.map((model) => model.value)); const MODEL_IDS = new Set(modelOptions.map((model) => model.value));
const MODEL_ORDER = new Map(modelOptions.map((model, index) => [model.value, index])); const MODEL_ORDER = new Map(modelOptions.map((model, index) => [model.value, index]));
const AUTOMATIC_SEAMLESS_MODEL_IDS = new Set( const AUTOMATIC_SEAMLESS_MODEL_IDS = new Set(
modelGroups.find((group) => group.value === 'automatic_seamless')?.models.map((model) => model.value) modelGroups
.find((group) => group.value === 'automatic_seamless')
?.models.map((model) => model.value)
); );
const COMPARE_MODEL_GROUPS = [
...modelGroups.filter((group) => group.value !== 'automatic_seamless'),
...modelGroups.filter((group) => group.value === 'automatic_seamless')
];
// Weather code and total cloud cover form one comparison feature. Cloud cover // Weather code and total cloud cover form one comparison feature. Cloud cover
// remains an auxiliary API field for pictogram shading, not a standalone chart. // remains an auxiliary API field for pictogram shading, not a standalone chart.
const COMPARISON_HOURLY = hourly.map((group) => const COMPARISON_HOURLY = hourly.map((group) =>
@@ -81,33 +86,40 @@
]; ];
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// the URL is the source of truth: location comes from the load function,
// which is also correct on hydrated prerendered pages. The persisted store
// only mirrors it so the header and bare /weather/* redirects follow along.
let location = $derived(data.location); let location = $derived(data.location);
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
let refreshing = $state(false);
let loadError = $state<FriendlyWeatherError | null>(null); let loadError = $state<FriendlyWeatherError | null>(null);
let retryNonce = $state(0); let retryNonce = $state(0);
let requestVersion = 0; let requestVersion = 0;
let activeController: AbortController | null = null; let activeController: AbortController | null = null;
let chartComponents: CanvasChart[] = $state([]); let chartComponents: CanvasChart[] = $state([]);
let pictogramExporter: ExportableChart | null = $state(null); let pictogramExporter: ExportableChart | null = $state(null);
let showLegend = $state(true);
let isMobile = $state(false); let isMobile = $state(false);
let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116); let showModelNames = $state(true);
let modelNamesInitialized = false;
let initialRangeInitialized = false;
let compactYAxis = $derived(!showModelNames);
let comparisonPlotInsetLeft = $derived(
showModelNames ? (isMobile ? 92 : 116) : isMobile ? 34 : 40
);
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20); let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
let selectionDrawerMode = $state<'models' | 'variables' | null>(null); const DEFAULT_MODELS = orderModels([
let params = $state({
...defaultParameters,
hourly: [...STANDARD_COMPARE_VARIABLES],
models: orderModels([
'ecmwf_ifs', 'ecmwf_ifs',
'meteofrance_arpege_world', 'meteofrance_arpege_world',
'ukmo_global_deterministic_10km', 'ukmo_global_deterministic_10km',
'icon_global', 'icon_global',
'gfs_global' 'gfs_global'
]) ]);
let params = $state({
...defaultParameters,
hourly: [...STANDARD_COMPARE_VARIABLES],
models: [...DEFAULT_MODELS]
}); });
let appliedHourly = $state<string[]>([...STANDARD_COMPARE_VARIABLES]); let appliedHourly = $state<string[]>([...STANDARD_COMPARE_VARIABLES]);
let appliedModels = $state<string[]>([...params.models]); let appliedModels = $state<string[]>([...params.models]);
@@ -130,6 +142,15 @@
return left.length === right.length && left.every((value, index) => value === right[index]); return left.length === right.length && left.every((value, index) => value === right[index]);
} }
function changedValueCount(left: string[], right: string[]): number {
const leftValues = new Set(left);
const rightValues = new Set(right);
let count = 0;
for (const value of leftValues) if (!rightValues.has(value)) count++;
for (const value of rightValues) if (!leftValues.has(value)) count++;
return count;
}
function orderModels(values: string[]): string[] { function orderModels(values: string[]): string[] {
return [...values].sort( return [...values].sort(
(left, right) => (left, right) =>
@@ -157,6 +178,8 @@
return current !== null && !hasSameOrder(current.selection.models, appliedModels); return current !== null && !hasSameOrder(current.selection.models, appliedModels);
}); });
let comparisonSelectionDirty = $derived(variablesDirty || modelsDirty); let comparisonSelectionDirty = $derived(variablesDirty || modelsDirty);
let pendingVariableChanges = $derived(changedValueCount(params.hourly, appliedHourly));
let pendingModelChanges = $derived(changedValueCount(params.models, appliedModels));
let comparisonMuted = $derived( let comparisonMuted = $derived(
comparisonSelectionDirty || displayedVariablesStale || displayedModelsStale comparisonSelectionDirty || displayedVariablesStale || displayedModelsStale
); );
@@ -196,6 +219,10 @@
const media = window.matchMedia('(max-width: 639px)'); const media = window.matchMedia('(max-width: 639px)');
const applyMedia = () => { const applyMedia = () => {
isMobile = media.matches; isMobile = media.matches;
if (!modelNamesInitialized) {
showModelNames = !media.matches;
modelNamesInitialized = true;
}
}; };
applyMedia(); applyMedia();
media.addEventListener('change', applyMedia); media.addEventListener('change', applyMedia);
@@ -209,9 +236,11 @@
const models = appliedModels; const models = appliedModels;
const vars = appliedHourly; const vars = appliedHourly;
if (!mounted) return; if (!mounted) return;
syncSearchParams(get(page).url, { // variables are always held in the canonical order, so compare the ordered
models: models.length ? models.join(',') : null, // list against the ordered defaults - only a real change reaches the URL
vars: vars.length ? vars.join(',') : null syncSearchParams({
models: listUnlessDefault(models, DEFAULT_MODELS),
vars: listUnlessDefault(orderVariables(vars), orderVariables(STANDARD_COMPARE_VARIABLES))
}); });
}); });
@@ -234,13 +263,12 @@
if (modelList.length === 0 || hourlyVars.length === 0) { if (modelList.length === 0 || hourlyVars.length === 0) {
fetchedData = null; fetchedData = null;
loading = false; loading = false;
refreshing = false;
loadError = null; loadError = null;
return; return;
} }
loading = fetchedData === null; const hasFetchedData = untrack(() => fetchedData !== null);
refreshing = fetchedData !== null; loading = !hasFetchedData;
loadError = null; loadError = null;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -263,15 +291,20 @@
) )
.then((result) => { .then((result) => {
if (version !== requestVersion) return; if (version !== requestVersion) return;
const applyInitialMobileRange = !initialRangeInitialized && isMobile;
initialRangeInitialized = true;
fetchedData = { result, selection: { models: modelList, hourly: hourlyVars } }; fetchedData = { result, selection: { models: modelList, hourly: hourlyVars } };
loading = false; loading = false;
refreshing = false; if (applyInitialMobileRange) {
void tick().then(() => {
if (version === requestVersion && groupRange(CHART_GROUP) === null) setRangeDays(3);
});
}
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (version !== requestVersion || controller.signal.aborted) return; if (version !== requestVersion || controller.signal.aborted) return;
loadError = humanizeWeatherError(error); loadError = humanizeWeatherError(error);
loading = false; loading = false;
refreshing = false;
}); });
}, FETCH_DEBOUNCE_MS); }, FETCH_DEBOUNCE_MS);
@@ -486,15 +519,12 @@
}); });
let exportLegend = $derived.by((): ExportLegendItem[] => [ let exportLegend = $derived.by((): ExportLegendItem[] => [
...displayedModels ...displayedModels.map((model) => ({
.map((model) => ({
name: modelLabel(model.modelId), name: modelLabel(model.modelId),
color: modelColor(model.modelId, fetchedData?.selection.models), color: modelColor(model.modelId, fetchedData?.selection.models),
style: 'point' as const style: 'point' as const
})), })),
...(chartDefs.some((def) => ...(chartDefs.some((def) => def.series.some((series) => series.name === m.compare_model_mean()))
def.series.some((series) => series.name === m.compare_model_mean())
)
? [{ name: m.compare_model_mean(), color: CHART_COLORS.average, style: 'dashed' as const }] ? [{ name: m.compare_model_mean(), color: CHART_COLORS.average, style: 'dashed' as const }]
: []) : [])
]); ]);
@@ -533,6 +563,11 @@
if (!variablesChanged && !modelsChanged) retryNonce++; if (!variablesChanged && !modelsChanged) retryNonce++;
} }
function discardComparisonSelection(): void {
params.hourly = [...appliedHourly];
params.models = [...appliedModels];
}
const variableGroupLabels = [ const variableGroupLabels = [
m.compare_group_temperature, m.compare_group_temperature,
m.compare_group_precipitation, m.compare_group_precipitation,
@@ -541,14 +576,8 @@
m.compare_group_upper_air m.compare_group_upper_air
]; ];
const allHourlyVariables = COMPARISON_HOURLY.flat(); const allHourlyVariables = COMPARISON_HOURLY.flat();
let selectedModels = $derived( let modelSelectionGroups = $derived(
params.models.flatMap((value) => { COMPARE_MODEL_GROUPS.map((group) => ({
const model = modelOptions.find((candidate) => candidate.value === value);
return model ? [model] : [];
})
);
let modelDrawerGroups = $derived(
modelGroups.map((group) => ({
value: group.value, value: group.value,
label: group.label, label: group.label,
options: group.models.map((model) => ({ options: group.models.map((model) => ({
@@ -558,17 +587,7 @@
})) }))
})) }))
); );
let selectedVariables = $derived( let variableSelectionGroups = $derived(
params.hourly.flatMap((value) => {
const variable = allHourlyVariables.find((candidate) => candidate.value === value);
return variable ? [variable] : [];
})
);
let standardVariablesActive = $derived(
params.hourly.length === STANDARD_COMPARE_VARIABLES.length &&
STANDARD_COMPARE_VARIABLES.every((variable) => params.hourly.includes(variable))
);
let variableDrawerGroups = $derived(
COMPARISON_HOURLY.map((variables, index) => ({ COMPARISON_HOURLY.map((variables, index) => ({
value: `variables_${index}`, value: `variables_${index}`,
label: variableGroupLabels[index](), label: variableGroupLabels[index](),
@@ -581,7 +600,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{m.page_compare_title()} | Drizz.li</title> <title>Drizz.li | {m.page_compare_title()}</title>
<link rel="canonical" href="https://drizz.li/weather/compare" /> <link rel="canonical" href="https://drizz.li/weather/compare" />
<meta name="description" content={m.page_compare_description()} /> <meta name="description" content={m.page_compare_description()} />
</svelte:head> </svelte:head>
@@ -610,6 +629,59 @@
{/snippet} {/snippet}
{#snippet heroActions()} {#snippet heroActions()}
<!-- Same footprint and look as the model selector on the other pages, so the
title row is the same height everywhere. Selection itself happens in the
multi-select panel further down, so this only hands over to it. -->
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<button
type="button"
class="group flex h-auto min-h-12 w-full min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 pe-3 text-left shadow-sm transition-colors hover:border-primary/70 hover:shadow-md sm:min-h-14 sm:w-80 sm:flex-none sm:gap-3 sm:py-2"
onclick={() =>
document.getElementById('models')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
>
<!-- layered-globe icon, matching the model selector -->
<svg
class="size-4.5 sm:size-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<circle cx="12" cy="12" r="9" />
<path
stroke-linecap="round"
d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"
/>
</svg>
</div>
<div class="flex min-w-0 flex-1 flex-col items-start gap-0 overflow-hidden">
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
{m.compare_models_heading()}
</span>
<span class="max-w-full truncate text-[13px] font-bold text-foreground sm:text-sm">
{params.models.length} / {modelOptions.length}
</span>
<span
class="hidden max-w-full truncate text-[11px] leading-tight text-muted-foreground sm:block"
>
{m.compare_models_choose()}
</span>
</div>
<svg
class="size-4 shrink-0 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
<div class="lg:absolute lg:right-0 lg:top-20 z-40 hidden items-center gap-3 lg:flex"> <div class="lg:absolute lg:right-0 lg:top-20 z-40 hidden items-center gap-3 lg:flex">
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{m.meteograms_zoom_hint()} {m.meteograms_zoom_hint()}
@@ -647,6 +719,8 @@
</div> </div>
{/if} {/if}
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<div class="relative">
{#if selectionEmpty} {#if selectionEmpty}
<div <div
class="rounded-xl border border-dashed border-border bg-muted/30 px-4 py-10 text-center text-sm text-muted-foreground" class="rounded-xl border border-dashed border-border bg-muted/30 px-4 py-10 text-center text-sm text-muted-foreground"
@@ -702,6 +776,7 @@
yMax={def.yMax} yMax={def.yMax}
yTicks={def.yTicks} yTicks={def.yTicks}
yTickFormat={def.yTickFormat} yTickFormat={def.yTickFormat}
{compactYAxis}
plotInsetLeft={comparisonPlotInsetLeft} plotInsetLeft={comparisonPlotInsetLeft}
plotInsetRight={comparisonPlotInsetRight} plotInsetRight={comparisonPlotInsetRight}
showCredit={i === chartDefs.length - 1} showCredit={i === chartDefs.length - 1}
@@ -724,28 +799,25 @@
<span class="text-xs font-semibold text-muted-foreground"> <span class="text-xs font-semibold text-muted-foreground">
{m.compare_selection_pending()} {m.compare_selection_pending()}
</span> </span>
<button
type="button"
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-70"
disabled={(displayedVariablesStale || displayedModelsStale) && refreshing}
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{:else} {:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<div in:fade={{ duration: 200 }} out:skeletonOut>
<ChartContainer <ChartContainer
loading loading
chartCount={appliedHourly.filter((v) => v !== 'weather_code' && v !== 'cloud_cover').length || 1} chartCount={appliedHourly.filter((v) => v !== 'weather_code' && v !== 'cloud_cover')
.length || 1}
chartHeight={300} chartHeight={300}
bleed={false} bleed={false}
/> />
</div>
{/if} {/if}
</div>
{#if fetchedData && !loading && appliedHourly.includes('weather_code')} {#if fetchedData && !loading && fetchedData.selection.hourly.includes('weather_code')}
<div <div
class="transition-[opacity,filter] duration-200 {comparisonMuted class="transition-[opacity,filter] duration-200 {comparisonMuted
? 'pointer-events-none opacity-35 saturate-50' ? 'pointer-events-none opacity-35 saturate-50'
@@ -761,6 +833,8 @@
group={CHART_GROUP} group={CHART_GROUP}
plotInsetLeft={comparisonPlotInsetLeft} plotInsetLeft={comparisonPlotInsetLeft}
plotInsetRight={comparisonPlotInsetRight} plotInsetRight={comparisonPlotInsetRight}
{showModelNames}
onToggleModelNames={() => (showModelNames = !showModelNames)}
registerExporter={(exporter) => (pictogramExporter = exporter)} registerExporter={(exporter) => (pictogramExporter = exporter)}
/> />
</div> </div>
@@ -772,18 +846,8 @@
? 'pointer-events-none opacity-40' ? 'pointer-events-none opacity-40'
: ''}" : ''}"
> >
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold text-muted-foreground">{m.compare_model_colors()}</p> <p class="text-xs font-semibold text-muted-foreground">{m.compare_model_colors()}</p>
<div class="flex items-center gap-2"> <div class="-mx-1 mt-2 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap">
<Switch id="show_legend" bind:checked={showLegend} />
<Label for="show_legend" class="text-xs">{m.legend_show()}</Label>
</div>
</div>
{#if showLegend}
<div
class="-mx-1 mt-2 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap"
transition:fade={{ duration: 150 }}
>
{#each displayedModels as model (model.modelId)} {#each displayedModels as model (model.modelId)}
<span class="flex min-h-8 shrink-0 items-center gap-1.5 text-xs"> <span class="flex min-h-8 shrink-0 items-center gap-1.5 text-xs">
<span <span
@@ -801,7 +865,6 @@
</span> </span>
{/if} {/if}
</div> </div>
{/if}
</div> </div>
<div <div
@@ -823,8 +886,29 @@
</div> </div>
{/if} {/if}
<!-- Compact summaries keep the page scannable; full editing happens in the shared drawer. --> <section
<section class="mt-6 border-t border-border/60 pt-5 md:mt-10" aria-labelledby="models"> class="mt-6 border-t border-border/60 pt-5 md:mt-10"
aria-labelledby="hourly_weather_variables"
>
<div class="flex items-baseline justify-between gap-3">
<h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
{m.compare_variables_heading()}
</h2>
<span class="shrink-0 text-sm text-muted-foreground">
{params.hourly.length}/{allHourlyVariables.length}
</span>
</div>
<ComparisonSelectionPanel
mode="variables"
groups={variableSelectionGroups}
selected={params.hourly}
onToggle={(value) => toggleSelection('hourly', value)}
onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)}
onRestoreDefaults={restoreStandardVariables}
/>
</section>
<section class="mt-5 border-t border-border/60 pt-5 md:mt-10" aria-labelledby="models">
<div class="flex items-baseline justify-between gap-3"> <div class="flex items-baseline justify-between gap-3">
<h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2> <h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2>
<span class="shrink-0 text-sm text-muted-foreground"> <span class="shrink-0 text-sm text-muted-foreground">
@@ -839,177 +923,50 @@
{m.compare_many_models_warning({ count: params.models.length })} {m.compare_many_models_warning({ count: params.models.length })}
</p> </p>
{/if} {/if}
<div class="mt-3 rounded-xl border border-border/70 bg-card p-3 shadow-sm sm:p-4"> <ComparisonSelectionPanel
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-bold">{m.compare_selected_models()}</h3>
{#if modelsDirty}
<span
class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold tracking-wide text-amber-700 uppercase dark:text-amber-300"
>
{m.compare_selection_pending()}
</span>
{/if}
</div>
{#if selectedModels.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each selectedModels as model, index (model.value)}
<button
type="button"
class="inline-flex min-h-8 max-w-full items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs hover:border-destructive/40 hover:bg-destructive/5"
onclick={() => toggleSelection('models', model.value)}
aria-label={m.compare_remove_model({ model: model.label })}
>
<span class="text-[10px] font-bold text-muted-foreground tabular-nums"
>{index + 1}</span
>
<span
class="size-2.5 shrink-0 rounded-full"
style:background-color={modelColor(model.value, params.models)}
></span>
<span class="truncate">{model.label}</span>
<span aria-hidden="true" class="text-muted-foreground">×</span>
</button>
{/each}
</div>
{:else}
<p class="mt-1.5 text-xs text-muted-foreground">{m.compare_no_models_selected()}</p>
{/if}
</div>
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={() => (selectionDrawerMode = 'models')}
aria-haspopup="dialog"
>
{m.compare_customize_models()}
</button>
{#if modelsDirty}
<button
type="button"
class="min-h-9 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90"
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
{/if}
</div>
</div>
</div>
</section>
<section
class="mt-5 border-t border-border/60 pt-5 md:mt-10"
aria-labelledby="hourly_weather_variables"
>
<div class="flex items-baseline justify-between gap-3">
<h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
{m.compare_variables_heading()}
</h2>
<span class="shrink-0 text-sm text-muted-foreground">
{params.hourly.length}/{allHourlyVariables.length}
</span>
</div>
<div
class="mt-3 rounded-xl border bg-card shadow-sm {standardVariablesActive
? 'border-primary/35'
: 'border-border/70'}"
>
<div class="flex flex-col gap-3 p-3 sm:flex-row sm:items-start sm:justify-between sm:p-4">
<div class="flex min-w-0 gap-3">
<span
class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border {standardVariablesActive
? 'border-primary bg-primary'
: 'border-muted-foreground/40 bg-muted'}"
aria-hidden="true"
>
{#if standardVariablesActive}<span class="size-2 rounded-full bg-primary-foreground"></span>{/if}
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-bold">{m.compare_standard_preset()}</h3>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase {variablesDirty
? 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
: standardVariablesActive
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'}"
>
{variablesDirty
? m.compare_variables_pending()
: standardVariablesActive
? m.compare_preset_active()
: m.compare_custom_selection({ count: params.hourly.length })}
</span>
</div>
<p class="mt-0.5 text-sm text-muted-foreground">
{m.compare_standard_preset_description()}
</p>
{#if selectedVariables.length > 0}
<p class="mt-2 text-xs leading-relaxed text-foreground/80">
{selectedVariables.map((variable) => variableLabel(variable.value)).join(' · ')}
</p>
{/if}
</div>
</div>
<div class="flex shrink-0 flex-wrap gap-2 sm:justify-end">
{#if !standardVariablesActive}
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={restoreStandardVariables}
>
{m.compare_restore_defaults()}
</button>
{/if}
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={() => (selectionDrawerMode = 'variables')}
aria-haspopup="dialog"
>
{m.compare_customize_variables()}
</button>
{#if variablesDirty}
<button
type="button"
class="min-h-9 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90"
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
{/if}
</div>
</div>
</div>
</section>
{#if selectionDrawerMode === 'models'}
<ComparisonSelectionDrawer
open
mode="models" mode="models"
groups={modelDrawerGroups} groups={modelSelectionGroups}
selected={params.models} selected={params.models}
dirty={comparisonSelectionDirty}
onToggle={(value) => toggleSelection('models', value)} onToggle={(value) => toggleSelection('models', value)}
onToggleGroup={(values, select) => toggleGroupSelection('models', values, select)} onToggleGroup={(values, select) => toggleGroupSelection('models', values, select)}
onApply={applyComparisonSelection}
onClose={() => (selectionDrawerMode = null)}
/>
{:else if selectionDrawerMode === 'variables'}
<ComparisonSelectionDrawer
open
mode="variables"
groups={variableDrawerGroups}
selected={params.hourly}
dirty={comparisonSelectionDirty}
onToggle={(value) => toggleSelection('hourly', value)}
onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)}
onApply={applyComparisonSelection}
onClose={() => (selectionDrawerMode = null)}
onRestoreDefaults={restoreStandardVariables}
/> />
</section>
{#if comparisonSelectionDirty}
<div class="h-24" aria-hidden="true"></div>
<div
class="pointer-events-none fixed right-3 bottom-3 left-3 z-50 md:left-auto md:w-[42rem] md:max-w-[calc(100vw-2rem)] lg:right-8"
>
<div
class="pointer-events-auto flex flex-col gap-3 rounded-2xl border border-primary/30 bg-background/95 p-3 shadow-2xl backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:p-4"
>
<div class="min-w-0" aria-live="polite">
<p class="text-sm font-bold">{m.compare_selection_pending()}</p>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{#if variablesDirty}
<span>{m.compare_variables_heading()}: {pendingVariableChanges}</span>
{/if}
{#if modelsDirty}
<span>{m.compare_models_heading()}: {pendingModelChanges}</span>
{/if}
</div>
</div>
<div class="grid shrink-0 grid-cols-2 gap-2">
<button
type="button"
class="min-h-10 rounded-lg border border-border bg-background px-3 py-2 text-sm font-semibold hover:bg-muted"
onclick={discardComparisonSelection}
>
{m.compare_discard_changes()}
</button>
<button
type="button"
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90"
onclick={applyComparisonSelection}
>
{m.compare_apply_selection()}
</button>
</div>
</div>
</div>
{/if} {/if}
@@ -1,233 +0,0 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages';
interface Option {
value: string;
label: string;
metadata?: string;
}
interface OptionGroup {
value: string;
label: string;
options: Option[];
}
interface Props {
open: boolean;
mode: 'models' | 'variables';
groups: OptionGroup[];
selected: string[];
dirty: boolean;
onToggle: (value: string) => void;
onToggleGroup: (values: string[], select: boolean) => void;
onApply: () => void;
onClose: () => void;
onRestoreDefaults?: () => void;
}
let {
open,
mode,
groups,
selected,
dirty,
onToggle,
onToggleGroup,
onApply,
onClose,
onRestoreDefaults
}: Props = $props();
let search = $state('');
let onlySelected = $state(false);
let normalizedSearch = $derived(search.trim().toLocaleLowerCase());
let visibleGroups = $derived.by(() =>
groups.flatMap((group) => {
const options = group.options.filter((option) => {
if (onlySelected && !selected.includes(option.value)) return false;
if (!normalizedSearch) return true;
return `${group.label} ${option.label} ${option.value} ${option.metadata ?? ''}`
.toLocaleLowerCase()
.includes(normalizedSearch);
});
return options.length > 0 ? [{ ...group, options }] : [];
})
);
$effect(() => {
if (open) return;
search = '';
onlySelected = false;
});
function groupSelection(group: OptionGroup): { count: number; all: boolean; partial: boolean } {
const count = group.options.filter((option) => selected.includes(option.value)).length;
return { count, all: count === group.options.length, partial: count > 0 && count < group.options.length };
}
function applyAndClose(): void {
onApply();
onClose();
}
</script>
<Dialog.Dialog {open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<Dialog.DialogContent
showCloseButton={false}
class="top-0 right-0 bottom-0 left-auto flex h-dvh w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 sm:w-[640px] sm:max-w-[90vw] sm:border-l"
>
<header class="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<Dialog.DialogTitle class="text-lg font-bold">
{mode === 'models' ? m.compare_edit_models() : m.compare_edit_variables()}
</Dialog.DialogTitle>
<Dialog.DialogDescription class="mt-0.5 text-xs">
{mode === 'models'
? m.compare_edit_models_description()
: m.compare_edit_variables_description()}
</Dialog.DialogDescription>
</div>
<button
type="button"
class="flex size-9 shrink-0 items-center justify-center rounded-lg text-xl text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={onClose}
aria-label={m.action_close()}
>
<span aria-hidden="true">×</span>
</button>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<Input
type="search"
bind:value={search}
placeholder={mode === 'models'
? m.compare_search_models()
: m.compare_search_variables()}
aria-label={mode === 'models'
? m.compare_search_models()
: m.compare_search_variables()}
/>
<div
class="flex min-h-9 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border px-3 text-xs font-semibold hover:bg-muted"
>
<Checkbox id="drawer_{mode}_only_selected" bind:checked={onlySelected} />
<Label for="drawer_{mode}_only_selected" class="cursor-pointer text-xs">
{m.compare_only_selected()}
</Label>
</div>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-5">
{#if visibleGroups.length > 0}
<div class="grid items-start gap-3 sm:grid-cols-2">
{#each visibleGroups as visibleGroup (visibleGroup.value)}
{@const fullGroup = groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup}
{@const selection = groupSelection(fullGroup)}
<section class="overflow-hidden rounded-xl border border-border/70 bg-card">
<div class="flex min-h-11 items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-2">
<Checkbox
id="drawer_{mode}_group_{fullGroup.value}"
checked={selection.all}
indeterminate={selection.partial}
onCheckedChange={() =>
onToggleGroup(
fullGroup.options.map((option) => option.value),
!selection.all
)}
/>
<Label
for="drawer_{mode}_group_{fullGroup.value}"
class="min-w-0 flex-1 cursor-pointer text-sm font-bold"
>
{fullGroup.label}
</Label>
<span class="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{selection.count}/{fullGroup.options.length}
</span>
<button
type="button"
class="shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold text-primary hover:bg-primary/10"
onclick={() =>
onToggleGroup(
fullGroup.options.map((option) => option.value),
!selection.all
)}
>
{selection.all ? m.compare_clear_group() : m.compare_select_group()}
</button>
</div>
<div class="p-2">
{#each visibleGroup.options as option (option.value)}
<div class="flex min-h-10 items-center rounded-lg px-1.5 hover:bg-muted/60">
<Checkbox
id="drawer_{mode}_{option.value}"
checked={selected.includes(option.value)}
onCheckedChange={() => onToggle(option.value)}
/>
<Label
for="drawer_{mode}_{option.value}"
class="min-w-0 flex-1 cursor-pointer py-2 pl-2"
>
<span class="block text-sm font-medium">{option.label}</span>
{#if option.metadata}
<span class="block text-[10px] text-muted-foreground">
{option.metadata}
</span>
{/if}
</Label>
</div>
{/each}
</div>
</section>
{/each}
</div>
{:else}
<div class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
{m.compare_no_matching_options()}
</div>
{/if}
</div>
<footer class="shrink-0 border-t border-border bg-background px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
{#if mode === 'variables' && onRestoreDefaults}
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={onRestoreDefaults}
>
{m.compare_restore_defaults()}
</button>
{/if}
</div>
<div class="flex gap-2">
<button
type="button"
class="min-h-10 rounded-lg border border-border px-4 py-2 text-sm font-semibold hover:bg-muted"
onclick={onClose}
>
{m.action_close()}
</button>
<button
type="button"
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={!dirty}
onclick={applyAndClose}
>
{m.compare_apply_selection()}
</button>
</div>
</div>
</footer>
</Dialog.DialogContent>
</Dialog.Dialog>
@@ -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,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { formatZoned } from '$lib/utils/date';
import { import {
groupHover, groupHover,
groupRange, groupRange,
@@ -9,7 +11,6 @@
setGroupRange setGroupRange
} from '$lib/charts'; } from '$lib/charts';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import { formatZoned } from '$lib/utils/date';
import { getWeatherIconName, hasWeatherIcon } from '../../utils/weather-codes'; import { getWeatherIconName, hasWeatherIcon } from '../../utils/weather-codes';
import { modelColor, modelLabel } from './comparison'; import { modelColor, modelLabel } from './comparison';
@@ -26,6 +27,8 @@
group: string; group: string;
plotInsetLeft: number; plotInsetLeft: number;
plotInsetRight: number; plotInsetRight: number;
showModelNames: boolean;
onToggleModelNames: () => void;
registerExporter?: (exporter: TimelineExporter | null) => void; registerExporter?: (exporter: TimelineExporter | null) => void;
} }
@@ -68,6 +71,8 @@
group, group,
plotInsetLeft, plotInsetLeft,
plotInsetRight, plotInsetRight,
showModelNames,
onToggleModelNames,
registerExporter registerExporter
}: Props = $props(); }: Props = $props();
@@ -136,10 +141,7 @@
const span = viewEnd - viewStart; const span = viewEnd - viewStart;
const nextSpan = span * factor; const nextSpan = span * factor;
const fraction = (centerTime - viewStart) / Math.max(1, span); const fraction = (centerTime - viewStart) / Math.max(1, span);
applyRange( applyRange(centerTime - fraction * nextSpan, centerTime + (1 - fraction) * nextSpan);
centerTime - fraction * nextSpan,
centerTime + (1 - fraction) * nextSpan
);
} }
function nearestIndex(values: number[], target: number): number { function nearestIndex(values: number[], target: number): number {
@@ -150,10 +152,7 @@
if (values[middle] < target) low = middle + 1; if (values[middle] < target) low = middle + 1;
else high = middle; else high = middle;
} }
if ( if (low > 0 && Math.abs(values[low - 1] - target) <= Math.abs(values[low] - target)) {
low > 0 &&
Math.abs(values[low - 1] - target) <= Math.abs(values[low] - target)
) {
return low - 1; return low - 1;
} }
return low; return low;
@@ -398,8 +397,7 @@
if (gesture === 'scroll') return; if (gesture === 'scroll') return;
if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) { if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) {
const deltaTime = const deltaTime =
((panStart.x - event.clientX) / Math.max(1, plotWidth)) * ((panStart.x - event.clientX) / Math.max(1, plotWidth)) * (panStart.end - panStart.start);
(panStart.end - panStart.start);
applyRange(panStart.start + deltaTime, panStart.end + deltaTime); applyRange(panStart.start + deltaTime, panStart.end + deltaTime);
return; return;
} }
@@ -600,9 +598,16 @@
context.beginPath(); context.beginPath();
context.arc(9, rowCenter, 4, 0, Math.PI * 2); context.arc(9, rowCenter, 4, 0, Math.PI * 2);
context.fill(); context.fill();
if (showModelNames) {
context.fillStyle = foreground; context.fillStyle = foreground;
context.textAlign = 'left'; context.textAlign = 'left';
context.fillText(modelLabel(model.modelId), 18, rowCenter, Math.max(20, plotInsetLeft - 24)); context.fillText(
modelLabel(model.modelId),
18,
rowCenter,
Math.max(20, plotInsetLeft - 24)
);
}
context.save(); context.save();
context.beginPath(); context.beginPath();
@@ -659,6 +664,7 @@
void daySeparators; void daySeparators;
void sunlightSegments; void sunlightSegments;
void displayModels; void displayModels;
void showModelNames;
if (!target || currentWidth <= 0 || currentHeight <= 0) return; if (!target || currentWidth <= 0 || currentHeight <= 0) return;
const version = ++screenRenderVersion; const version = ++screenRenderVersion;
@@ -684,12 +690,22 @@
class="mt-7 -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border" 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" aria-labelledby="model-pictogram-title"
> >
<div class="px-3 pt-3 pb-2 lg:px-4"> <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"> <h2 id="model-pictogram-title" class="text-sm font-bold tracking-tight">
{m.compare_timeline_title()} {m.compare_timeline_title()}
</h2> </h2>
<p class="text-xs text-muted-foreground">{m.compare_timeline_hint()}</p> <p class="text-xs text-muted-foreground">{m.compare_timeline_hint()}</p>
</div> </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 class="lg:px-4">
<div <div
bind:this={containerEl} bind:this={containerEl}
@@ -740,9 +756,7 @@
</div> </div>
{#each tooltipRows as row (row.modelId)} {#each tooltipRows as row (row.modelId)}
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5"> <div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
<span <span class="size-2 shrink-0 rounded-full" style:background-color={row.color}
class="size-2 shrink-0 rounded-full"
style:background-color={row.color}
></span> ></span>
<span class="min-w-0 break-words">{row.label}:</span> <span class="min-w-0 break-words">{row.label}:</span>
<span class="pl-2 text-right font-semibold whitespace-nowrap"> <span class="pl-2 text-right font-semibold whitespace-nowrap">
@@ -13,17 +13,19 @@
storedVariablePrefs storedVariablePrefs
} from '$lib/stores/settings'; } from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer } from '$lib/components/charts'; import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import { import {
type ClimateNormals, type ClimateNormals,
type HistoricalForecastResult, type HistoricalForecastResult,
fetchClimateNormals, fetchClimateNormals,
fetchHistoricalWeather fetchHistoricalWeather
} from '$lib/services/weather'; } from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte'; import { useHeroActions } from '../../hero.svelte';
import { archiveModelGroups, defaultParameters } from '../../options'; import { archiveModelGroups, defaultParameters } from '../../options';
@@ -39,8 +41,10 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page // The page cross-fade waits for this before revealing the new page - and so
reportPageReady(() => result != null); // 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); useHeroActions(heroActions);
@@ -213,13 +217,15 @@
); );
function switchDay(date: Date) { 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> </script>
<!-- the reanalysis picker rides in the layout's location row --> <!-- the reanalysis picker rides in the layout's location row -->
{#snippet heroActions()} {#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 <ModelSelector
selectedModel={archiveModel} selectedModel={archiveModel}
groups={archiveModelGroups} groups={archiveModelGroups}
@@ -237,7 +243,7 @@
<meta name="description" content="Past weather and climate-normal comparisons for any location" /> <meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head> </svelte:head>
<PaywallGate feature={m.page_historical_subtitle()}> <SupporterGate feature={m.page_historical_subtitle()}>
<DateRangeControls <DateRangeControls
start={startDate} start={startDate}
end={endDate} end={endDate}
@@ -254,7 +260,7 @@
</div> </div>
{/if} {/if}
<div class="mt-4"> <div class="relative mt-4">
{#if result && fetchedHourly && fetchedDaily} {#if result && fetchedHourly && fetchedDaily}
<HistoricalDaily <HistoricalDaily
daily={result.daily} daily={result.daily}
@@ -278,10 +284,10 @@
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} /> <HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
{:else} {: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> <div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} /> <ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
</div> </div>
{/if} {/if}
</div> </div>
</PaywallGate> </SupporterGate>
@@ -9,7 +9,7 @@
} from '$lib/services/weather'; } from '$lib/services/weather';
import { getColor, getTempStyle } from '../../utils/colors'; 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'; import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
interface Props { interface Props {
@@ -202,7 +202,8 @@
>{formatZoned(date, timezone, 'd MMM')}</span >{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 <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
daily.weather_code[i], daily.weather_code[i],
@@ -31,7 +31,7 @@
let chartComponents: (CanvasChart | null)[] = $state([]); let chartComponents: (CanvasChart | null)[] = $state([]);
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null)); 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( let panels = $derived(
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k))) $storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
); );
+35 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
import { mapsDomainForModel } from '$lib/utils/maps-domain'; import { mapsDomainForModel } from '$lib/utils/maps-domain';
@@ -16,6 +18,23 @@
let hashOverride = $state<string | null>(null); let hashOverride = $state<string | null>(null);
let iframeEl = $state<HTMLIFrameElement | null>(null); let iframeEl = $state<HTMLIFrameElement | null>(null);
let mapReady = $state(false); 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>) => { const postToMap = (message: Record<string, unknown>) => {
iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN); iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN);
@@ -83,7 +102,7 @@
<!-- Full-bleed map: the layout drops its padding for this route. The map <!-- Full-bleed map: the layout drops its padding for this route. The map
follows our theme through the color-scheme declared on :root/.dark --> 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 <!-- allow="cross-origin-isolated" delegates SharedArrayBuffer use to the
map; it only takes effect when this site itself is served with map; it only takes effect when this site itself is served with
COOP/COEP headers (see README, Deployment) --> COOP/COEP headers (see README, Deployment) -->
@@ -95,7 +114,22 @@
allowfullscreen allowfullscreen
allow="cross-origin-isolated" allow="cross-origin-isolated"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
onload={() => (frameLoaded = true)}
class="block h-full w-full border-0" class="block h-full w-full border-0"
class:invisible={$mapTransitionCover}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe> ></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> </div>
@@ -6,19 +6,21 @@
import { reportPageReady } from '$lib/stores/page-transition.svelte'; import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings'; import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import { import {
type ClimateNormals, type ClimateNormals,
type SeasonalForecastResult, type SeasonalForecastResult,
fetchClimateNormals, fetchClimateNormals,
fetchSeasonalForecast fetchSeasonalForecast
} from '$lib/services/weather'; } from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte'; import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, seasonalModelGroups } from '../../options'; import { defaultParameters, seasonalModelGroups } from '../../options';
@@ -33,8 +35,10 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page // The page cross-fade waits for this before revealing the new page - and so
reportPageReady(() => result != null); // 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); useHeroActions(heroActions);
@@ -189,16 +193,27 @@
<!-- the range buttons ride in the layout's location row (see weather/+layout) --> <!-- the range buttons ride in the layout's location row (see weather/+layout) -->
{#snippet heroActions()} {#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"> <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). While a
<!-- range buttons reslice the already-fetched horizon (no refetch) --> 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 <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" role="group"
aria-label={m.seasonal_range_aria()} aria-label={m.seasonal_range_aria()}
aria-hidden={!result}
> >
{#each RANGES as range, i (range.label)} {#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 <button
type="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 === 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} aria-pressed={rangeIndex === i}
{disabled} {disabled}
tabindex={result ? undefined : -1}
onclick={() => (rangeIndex = i)} onclick={() => (rangeIndex = i)}
> >
{range.label} {range.label}
@@ -253,7 +269,7 @@
</p> </p>
</div> </div>
<PaywallGate feature={m.page_seasonal_subtitle()}> <SupporterGate feature={m.page_seasonal_subtitle()}>
{#if loadError} {#if loadError}
<div <div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive" class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
@@ -262,6 +278,9 @@
</div> </div>
{/if} {/if}
<!-- `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} {#if visible && months.length > 0}
<SeasonalMonths {months} units={params} {normals} /> <SeasonalMonths {months} units={params} {normals} />
@@ -289,7 +308,7 @@
</ChartToolbar> </ChartToolbar>
</div> </div>
{:else if !loadError} {:else if !loadError}
<div transition:fade={{ duration: 200 }} class="grid gap-3"> <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"> <div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each [0, 1, 2, 3, 4, 5] as i (i)} {#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> <div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
@@ -298,4 +317,5 @@
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} /> <ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
</div> </div>
{/if} {/if}
</PaywallGate> </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);
});
});
+83 -23
View File
@@ -1,8 +1,10 @@
import * as m from '$lib/paraglide/messages';
const weatherCodes: Record<number, string> = { const weatherCodes: Record<number, string> = {
0: 'clear', 0: 'clear',
1: 'clear', 1: 'clear',
2: 'cloudy', 2: 'cloudy',
3: 'cloudy', 3: 'overcast',
4: 'fog', 4: 'fog',
5: 'fog', 5: 'fog',
10: 'fog', 10: 'fog',
@@ -30,26 +32,26 @@ const weatherCodes: Record<number, string> = {
42: 'rain', 42: 'rain',
43: 'sprinkle', 43: 'sprinkle',
44: 'rain', 44: 'rain',
45: 'hail', 45: 'fog',
46: 'hail', 46: 'hail',
47: 'snow', 47: 'snow',
48: 'snow', 48: 'fog',
50: 'sprinkle', 50: 'sprinkle',
51: 'sprinkle', 51: 'sprinkle',
52: 'rain', 52: 'rain',
53: 'rain', 53: 'sprinkle',
54: 'sprinkle', 54: 'sprinkle',
55: 'rain', 55: 'rain',
56: 'rain-mix', 56: 'rain-mix',
57: 'sprinkle', 57: 'rain-mix',
58: 'rain', 58: 'rain',
60: 'sprinkle', 60: 'sprinkle',
61: 'sprinkle', 61: 'sprinkle',
62: 'rain', 62: 'rain',
63: 'rain', 63: 'rain',
64: 'hail', 64: 'hail',
65: 'hail', 65: 'rain',
66: 'hail', 66: 'rain-mix',
67: 'rain-mix', 67: 'rain-mix',
68: 'rain-mix', 68: 'rain-mix',
70: 'snow', 70: 'snow',
@@ -61,13 +63,13 @@ const weatherCodes: Record<number, string> = {
76: 'snow', 76: 'snow',
77: 'snow', 77: 'snow',
78: 'snow', 78: 'snow',
80: 'rain', 80: 'showers',
81: 'sprinkle', 81: 'showers',
82: 'rain', 82: 'rain',
83: 'rain', 83: 'rain',
84: 'storm-showers', 84: 'storm-showers',
85: 'rain-mix', 85: 'snow',
86: 'rain-mix', 86: 'snow',
87: 'rain-mix', 87: 'rain-mix',
89: 'hail', 89: 'hail',
90: 'lightning', 90: 'lightning',
@@ -77,11 +79,19 @@ const weatherCodes: Record<number, string> = {
94: 'lightning', 94: 'lightning',
95: 'thunderstorm', 95: 'thunderstorm',
96: 'thunderstorm', 96: 'thunderstorm',
99: 'tornado' 99: 'storm-showers'
}; };
// These conditions ship only as a single neutral glyph (no day/night variant). // Conditions that ship as a single neutral glyph (no day/night variant). The
const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']); // 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 { export function hasWeatherIcon(code: unknown): code is number {
return ( return (
@@ -93,10 +103,52 @@ export function hasWeatherIcon(code: unknown): code is number {
export function getWeatherIconName(code: number, daytime: boolean): string { export function getWeatherIconName(code: number, daytime: boolean): string {
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear'; const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`; const neutral = NEUTRAL_ICONS[name];
if (neutral) return neutral;
return `wi-${daytime ? 'day' : 'night'}-${name}`; 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 ────────────────────────────────────────── // ─── Local day/night weather codes ──────────────────────────────────────────
// Open-meteo's daily weather_code is a plain numeric max over all 24 hourly // 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 // codes (VariableDaily.swift: `.max(.weathercode)`) — there is no day/night
@@ -121,8 +173,17 @@ const FOG = new Set([45, 48]);
// counterparts when breaking frequency ties (fixes 80 "outranking" 65). // 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 }; 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>(); const counts = new Map<number, number>();
for (const c of codes) counts.set(c, (counts.get(c) ?? 0) + 1); for (const c of codes) counts.set(c, (counts.get(c) ?? 0) + 1);
let best = codes[0]; let best = codes[0];
@@ -130,11 +191,10 @@ function modeWithHighTiebreak(codes: number[]): number {
for (const [code, count] of counts) { for (const [code, count] of counts) {
const intensity = INTENSITY_EQUIV[code] ?? code; const intensity = INTENSITY_EQUIV[code] ?? code;
const bestIntensity = INTENSITY_EQUIV[best] ?? best; const bestIntensity = INTENSITY_EQUIV[best] ?? best;
if ( const winsTie = preferLower
count > bestCount || ? intensity < bestIntensity || (intensity === bestIntensity && code < best)
(count === bestCount && : intensity > bestIntensity || (intensity === bestIntensity && code > best);
(intensity > bestIntensity || (intensity === bestIntensity && code > best))) if (count > bestCount || (count === bestCount && winsTie)) {
) {
best = code; best = code;
bestCount = count; bestCount = count;
} }
@@ -152,7 +212,7 @@ function daypartCode(codes: number[]): number | null {
// (open-meteo's "don't hide hazards" philosophy, kept per-group). // (open-meteo's "don't hide hazards" philosophy, kept per-group).
for (const group of [THUNDER, FREEZING, SNOW, RAIN, DRIZZLE]) { for (const group of [THUNDER, FREEZING, SNOW, RAIN, DRIZZLE]) {
const hits = hours.filter((c) => group.has(c)); 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 // Fog needs persistence (≥2h and ≥¼ of the daypart) so one misty hour at
+70 -23
View File
@@ -18,7 +18,8 @@
import { formatZoned } from '$lib/utils/date'; import { formatZoned } from '$lib/utils/date';
import { daySwap, runDayTransition } from '$lib/utils/day-swap'; import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location'; 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'; import { ChartContainer } from '$lib/components/charts';
@@ -49,7 +50,7 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page // 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); useHeroActions(heroActions);
@@ -85,10 +86,21 @@
// arrives (no layout shift) // arrives (no layout shift)
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length); 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 // The hourly table is a header row, the time/daylight row, then one row per
// placeholder below reserves exactly that and nothing under it jumps when the // enabled variable, so the placeholder below reserves exactly that and
// real table arrives. // nothing under it jumps when the real table arrives. Rows are shorter below
const TABLE_ROW_PX = 57; // 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); let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
// Request only the hourly variables the table rows and meteograms actually // 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 // Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll. // 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) => { const switchDay = (date: Date) => {
runDayTransition(() => selectedDay.setTime(date.getTime())); const time = date?.getTime();
if (!Number.isFinite(time)) return;
runDayTransition(() => selectedDay.setTime(time));
}; };
onMount(() => { onMount(() => {
@@ -256,9 +273,9 @@
const isToday = fetchedDaily const isToday = fetchedDaily
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd') ? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
: false; : false;
syncSearchParams(get(page).url, { syncSearchParams({
day: isToday ? null : dayKey, day: isToday ? null : dayKey,
model: model && model !== 'best_match' ? model : null model: unlessDefault(model, 'best_match')
}); });
}); });
@@ -329,12 +346,12 @@
<svelte:head> <svelte:head>
<title>Drizz.li | {m.page_week_title()}</title> <title>Drizz.li | {m.page_week_title()}</title>
<link rel="canonical" href="https://drizz.li/weather/week" /> <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> </svelte:head>
<!-- the model picker rides in the layout's location row (see weather/+layout) --> <!-- the model picker rides in the layout's location row (see weather/+layout) -->
{#snippet heroActions()} {#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 <ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'} selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => { onModelChange={(model) => {
@@ -457,6 +474,9 @@
{locationRoute} {locationRoute}
/> />
<!-- `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} {#if fetchedHourly && fetchedDaily}
<div class="day-region-table" use:daySwap={selectedDayKey}> <div class="day-region-table" use:daySwap={selectedDayKey}>
<HourlyTable <HourlyTable
@@ -470,12 +490,10 @@
</div> </div>
{:else} {:else}
<!-- Mirrors the real table: same header bar and the same body height, <!-- Mirrors the real table: same header bar and the same body height,
so the heading doesn't pop in and nothing below moves. 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 <div
in:fade={{ duration: 200 }} 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" class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
> >
<div <div
@@ -490,8 +508,13 @@
<!-- one placeholder per row the real table will render, so the body <!-- one placeholder per row the real table will render, so the body
reads as a loading table rather than a blank panel --> reads as a loading table rather than a blank panel -->
<div class="divide-y divide-border/50"> <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)} {#each { length: enabledTableRows } as _, i (i)}
<div class="flex items-center gap-4 px-4" style="height: {TABLE_ROW_PX}px"> <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 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="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div> </div>
@@ -499,19 +522,29 @@
</div> </div>
</div> </div>
{/if} {/if}
</div>
<div class="relative">
{#if fetchedHourly && fetchedDaily} {#if fetchedHourly && fetchedDaily}
<div class="day-region-summary" use:daySwap={selectedDayKey}> <div class="day-region-summary" use:daySwap={selectedDayKey}>
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} /> <DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
</div> </div>
{:else} {:else}
<!-- same footprint as the written forecast, so it doesn't shove the <!-- Same footprint as the written forecast, so it doesn't shove the
meteograms down when it arrives --> meteograms down when it arrives. The heights are measured from the
<section class="mt-6" in:fade={{ duration: 200 }}> real card at each breakpoint: on phones the narrative is clamped to
<div class="h-52 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-40"></div> 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> </section>
{/if} {/if}
</div>
<div class="relative">
{#if fetchedHourly} {#if fetchedHourly}
<div class="day-region-charts" use:daySwap={selectedDayKey}> <div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts <MeteogramCharts
@@ -525,17 +558,31 @@
{:else} {:else}
<!-- reserve the exact chart area height before the first fetch resolves, <!-- reserve the exact chart area height before the first fetch resolves,
header row included --> header row included -->
<section class="mt-8" in:fade={{ duration: 200 }}> <section class="mt-8" in:fade={{ duration: 200 }} out:skeletonOut>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <!-- 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="h-7 w-52 animate-pulse rounded bg-muted"></div>
<div class="flex items-center gap-3"> <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-56 animate-pulse rounded-lg bg-muted"></div>
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div> <div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
</div> </div>
</div> </div>
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} /> <!-- 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> </section>
{/if} {/if}
</div>
{#if selectedDayKey} {#if selectedDayKey}
<NearbyCities <NearbyCities
@@ -6,7 +6,7 @@
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors'; 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 { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types'; import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
@@ -196,6 +196,7 @@
height="100px" height="100px"
style="filter: url(#thin-day-icon)" style="filter: url(#thin-day-icon)"
> >
<title>{getWeatherDescription(dayCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode, dayCode,
@@ -208,6 +209,7 @@
width="42px" width="42px"
height="42px" height="42px"
> >
<title>{getWeatherDescription(nightCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode, nightCode,
@@ -7,7 +7,7 @@
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { import {
getSunshineColor, getSunshineColor,
getSunshinePercent, getSunshinePercent,
@@ -276,6 +276,7 @@
<div class="icon-row flex w-full items-center justify-center"> <div class="icon-row flex w-full items-center justify-center">
<div class="icon-wrap"> <div class="icon-wrap">
<svg class="day-icon fill-foreground"> <svg class="day-icon fill-foreground">
<title>{getWeatherDescription(dayCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode, dayCode,
@@ -285,6 +286,7 @@
</svg> </svg>
</div> </div>
<svg class="night-icon self-end fill-foreground/60"> <svg class="night-icon self-end fill-foreground/60">
<title>{getWeatherDescription(nightCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode, 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`; 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); 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> </script>
<section class="mt-6" aria-label={m.summary_heading()}> <section class="mt-6" aria-label={m.summary_heading()}>
@@ -115,8 +144,13 @@
</div> </div>
<div class="grid gap-4 px-4 py-3.5 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-6"> <div class="grid gap-4 px-4 py-3.5 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-6">
<div class="narrative">
{#if sentences.length > 0} {#if sentences.length > 0}
<p class="text-[15px] leading-relaxed text-foreground"> <p
bind:this={textEl}
class="text-[15px] leading-relaxed text-foreground"
class:clamped={!expanded}
>
{sentences.join(' ')} {sentences.join(' ')}
</p> </p>
{:else} {:else}
@@ -125,6 +159,21 @@
</p> </p>
{/if} {/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 --> <!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
<dl <dl
class="grid grid-cols-2 gap-x-5 gap-y-2.5 text-sm sm:grid-cols-3 lg:w-80 lg:grid-cols-2 lg:border-l lg:border-border/60 lg:pl-6" class="grid grid-cols-2 gap-x-5 gap-y-2.5 text-sm sm:grid-cols-3 lg:w-80 lg:grid-cols-2 lg:border-l lg:border-border/60 lg:pl-6"
@@ -218,3 +267,36 @@
</div> </div>
</div> </div>
</section> </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 * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { import {
type FetchedDaily, type FetchedDaily,
type FetchedHourly, type FetchedHourly,
@@ -322,6 +322,10 @@
// positioned relative to the data columns only. // positioned relative to the data columns only.
let headerColWidth = $state(0); let headerColWidth = $state(0);
let tableWidth = $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( let nowLeftPx = $derived(
nowPercent != null && tableWidth > 0 nowPercent != null && tableWidth > 0
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth) ? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
@@ -425,8 +429,9 @@
}); });
</script> </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}> <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> <use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg> </svg>
{/snippet} {/snippet}
@@ -607,14 +612,17 @@
{#if nowPercent != null} {#if nowPercent != null}
<div <div
class="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-red-500/75" 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> ></div>
{/if} {/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} {#if isTodaySelected && nowPercent != null}
<span <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" 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()} {m.table_now()}
</span> </span>
@@ -678,7 +686,11 @@
!cellData[i + 1].isDaytime} !cellData[i + 1].isDaytime}
> >
{#if finite(wCode)} {#if finite(wCode)}
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)} {@render weatherIcon(
getWeatherIconName(wCode, cell.isDaytime),
iconPx,
getWeatherDescription(wCode)
)}
{/if} {/if}
</td> </td>
{/each} {/each}
@@ -899,24 +911,31 @@
</tbody> </tbody>
</table> </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} {#if hoveredCol >= 0 && tableWidth > 0}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length} {@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const colLeft = headerColWidth + hoveredCol * colWidth}
<div <div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10" 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> ></div>
{/if} {/if}
<!-- "Now" column highlight. The current-time line itself is painted per <!-- "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} {#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length} {@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const nowIdx = cellData.findIndex((c) => c.isNow)} {@const nowIdx = cellData.findIndex((c) => c.isNow)}
{#if nowIdx >= 0} {#if nowIdx >= 0}
{@const colLeft = headerColWidth + nowIdx * colWidth}
<div <div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5" 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> ></div>
{/if} {/if}
{/if} {/if}
@@ -64,9 +64,13 @@
if (val) onModelChange(val); 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 <Select.Trigger
aria-label={m.model_selector_aria({ label })} 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 <div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9" 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 NearbyCity, findNearbyCities } from '$lib/services/nearby-cities';
import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather'; 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'; import type { UnitPrefs } from '$lib/stores/settings';
@@ -113,6 +113,7 @@
> >
<svg class="shrink-0 fill-foreground/80" width="34px" height="34px"> <svg class="shrink-0 fill-foreground/80" width="34px" height="34px">
{#if day} {#if day}
<title>{getWeatherDescription(day.weatherCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
day.weatherCode, 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}`)); 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 { try {
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json'); const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
const raw = fs.readFileSync(citiesPath, 'utf-8'); const raw = fs.readFileSync(citiesPath, 'utf-8');
const cities = JSON.parse(raw); const cities = JSON.parse(raw);
if (Array.isArray(cities)) { 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 // Keep the default wildcard to include other routes
return ['*', ...localized, ...cityEntries]; return ['*', ...localized, ...cityEntries];
} }