Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9f1df8f0 | ||
|
|
032b652675 | ||
|
|
83b0e46e64 | ||
|
|
d0f94094ca | ||
|
|
810f9c2605 | ||
|
|
c4a74ba91c | ||
|
|
c3bb6de49c | ||
|
|
1c5ea2b52e | ||
|
|
f4a02a208c | ||
|
|
025623aaa6 | ||
|
|
dbfa38f6c5 | ||
|
|
9fc17e7417 | ||
|
|
f66e259e32 |
+13
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+38
-5
@@ -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",
|
||||||
@@ -385,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"
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-5
@@ -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",
|
||||||
@@ -385,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"
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-8
@@ -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",
|
||||||
@@ -385,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"
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-8
@@ -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",
|
||||||
@@ -385,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"
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-8
@@ -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",
|
||||||
@@ -385,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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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},
|
|
||||||
{/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) -->
|
||||||
<SupporterBadge />
|
{#if SUPPORTER_ENABLED}
|
||||||
|
<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,29 +77,34 @@
|
|||||||
|
|
||||||
<LanguageOptions onSelect={() => (open = false)} />
|
<LanguageOptions onSelect={() => (open = false)} />
|
||||||
|
|
||||||
<div class="border-t border-border/70 pt-3">
|
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
|
||||||
<button
|
{#if SUPPORTER_ENABLED}
|
||||||
type="button"
|
<div class="border-t border-border/70 pt-3">
|
||||||
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
|
<button
|
||||||
onclick={() => {
|
type="button"
|
||||||
open = false;
|
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
|
||||||
unlockOpen = true;
|
onclick={() => {
|
||||||
}}
|
open = false;
|
||||||
>
|
unlockOpen = true;
|
||||||
<SupporterIcon
|
}}
|
||||||
filled={$isSupporter}
|
>
|
||||||
class="h-4.5 w-4.5 shrink-0 {$isSupporter ? 'text-amber-500' : 'text-muted-foreground'}"
|
<SupporterIcon
|
||||||
/>
|
filled={$isSupporter}
|
||||||
<span class="min-w-0 flex-1">
|
class="h-4.5 w-4.5 shrink-0 {$isSupporter
|
||||||
<span class="block text-[13px] font-semibold">
|
? 'text-amber-500'
|
||||||
{$isSupporter ? m.supporter_active() : m.supporter_support()}
|
: 'text-muted-foreground'}"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block text-[13px] font-semibold">
|
||||||
|
{$isSupporter ? m.supporter_active() : m.supporter_support()}
|
||||||
|
</span>
|
||||||
|
<span class="block text-[11px] text-muted-foreground">
|
||||||
|
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="block text-[11px] text-muted-foreground">
|
</button>
|
||||||
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
|
</div>
|
||||||
</span>
|
{/if}
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+49
-13
@@ -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.
|
||||||
update();
|
await startViewTransition(
|
||||||
await tick();
|
async () => {
|
||||||
}).finished;
|
update();
|
||||||
} catch {
|
await tick();
|
||||||
/* a superseded transition is fine - the DOM is already up to date */
|
},
|
||||||
|
{ rootClass: 'day-switch' }
|
||||||
|
);
|
||||||
} 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)'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Central entry point for every view transition in the app.
|
||||||
|
*
|
||||||
|
* Two things it exists to get right.
|
||||||
|
*
|
||||||
|
* **One transition at a time, but only where it matters.** Starting a
|
||||||
|
* transition while another is running makes the browser skip the first, and
|
||||||
|
* what that looks like depends entirely on which phase the first is in:
|
||||||
|
*
|
||||||
|
* - *capturing* - its update callback has not resolved yet, so the screen is
|
||||||
|
* frozen on the outgoing snapshot. Skipping drops that snapshot on the spot
|
||||||
|
* and pops the half-updated live DOM into view: the whole-screen flash.
|
||||||
|
* This phase is common here, because navigation deliberately holds the
|
||||||
|
* callback open while the new page fetches, and `/` plus every bare
|
||||||
|
* `/weather/<view>/` page is a redirect stub that navigates again from
|
||||||
|
* `onMount` - one click, two or three navigations.
|
||||||
|
* - *animating* - the DOM is already in its final state and the pseudo
|
||||||
|
* elements are playing out. Skipping just finishes them early, landing on
|
||||||
|
* exactly the state they were heading for.
|
||||||
|
*
|
||||||
|
* So a new transition rides along inside the running one only while it is
|
||||||
|
* capturing; once it is animating, superseding it is the better answer (waiting
|
||||||
|
* would strand the new update under a stale snapshot until the animation ends).
|
||||||
|
*
|
||||||
|
* **A scoping class.** `rootClass` is set on `<html>` for the life of the
|
||||||
|
* transition, so the stylesheet can tell a page swap from a day switch and pin
|
||||||
|
* the parts that are identical on both sides (see routes/layout.css).
|
||||||
|
*/
|
||||||
|
type UpdateCallback = () => void | Promise<void>;
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** Class set on `<html>` while the transition runs, for scoping CSS. */
|
||||||
|
rootClass?: string;
|
||||||
|
/**
|
||||||
|
* Set false to run the update without a transition. For content the browser
|
||||||
|
* does not paint into a snapshot - a cross-origin iframe - where animating
|
||||||
|
* means animating a hole rather than a cross-fade.
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
|
||||||
|
let capturing: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many running transitions hold each scoping class. A superseded
|
||||||
|
* transition's cleanup fires while its successor is mid-capture (skipping
|
||||||
|
* rejects `finished` on a microtask, which runs before the next render);
|
||||||
|
* without the count it would strip the class out from under the successor,
|
||||||
|
* and a day switch captured without `day-switch` falls back to the full-page
|
||||||
|
* fade it exists to prevent.
|
||||||
|
*/
|
||||||
|
const rootClassHolds = new Map<string, number>();
|
||||||
|
|
||||||
|
/** The most recently started transition, while it is capturing or animating. */
|
||||||
|
let active: ViewTransition | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finishes the running transition's animation on the spot (the DOM is already
|
||||||
|
* in its final state, so this is always safe). Used to hand the screen back
|
||||||
|
* the moment the user starts scrolling under a day switch, rather than
|
||||||
|
* animating against a moving target.
|
||||||
|
*/
|
||||||
|
export function skipActiveViewTransition(): void {
|
||||||
|
active?.skipTransition();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supportsViewTransitions = (): boolean =>
|
||||||
|
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
|
||||||
|
|
||||||
|
export const prefersReducedMotion = (): boolean =>
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while a transition is frozen on its outgoing snapshot - the phase in
|
||||||
|
* which starting a rival transition would flash the page.
|
||||||
|
*/
|
||||||
|
export const isViewTransitionCapturing = (): boolean => capturing !== null;
|
||||||
|
|
||||||
|
/** Whether `startViewTransition` would actually open one right now. */
|
||||||
|
export const canStartViewTransition = (): boolean =>
|
||||||
|
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionCapturing();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `update` inside a view transition, or straight away when one cannot (or
|
||||||
|
* must not) be started. Resolves once the transition has finished animating -
|
||||||
|
* or as soon as the update is done, when it ran on its own.
|
||||||
|
*/
|
||||||
|
export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
|
||||||
|
const { rootClass, enabled = true } = options;
|
||||||
|
|
||||||
|
if (!enabled || !canStartViewTransition()) {
|
||||||
|
return Promise.resolve(update()).then(
|
||||||
|
() => {},
|
||||||
|
(error: unknown) => {
|
||||||
|
console.error('view transition update failed', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (rootClass) {
|
||||||
|
rootClassHolds.set(rootClass, (rootClassHolds.get(rootClass) ?? 0) + 1);
|
||||||
|
root.classList.add(rootClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transition = document.startViewTransition(update);
|
||||||
|
active = transition;
|
||||||
|
|
||||||
|
// A throw inside the callback rejects `updateCallbackDone` as well as
|
||||||
|
// `finished`. Nothing awaits the former, and an unhandled rejection there is
|
||||||
|
// what turns one bad render into an "Uncaught" error on the page.
|
||||||
|
const captured = transition.updateCallbackDone.then(
|
||||||
|
() => {},
|
||||||
|
(error: unknown) => {
|
||||||
|
console.error('view transition update failed', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
capturing = captured;
|
||||||
|
void captured.then(() => {
|
||||||
|
if (capturing === captured) capturing = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A superseded transition rejects `finished`; the DOM is already up to date,
|
||||||
|
// so that is not an error worth surfacing.
|
||||||
|
return transition.finished
|
||||||
|
.then(
|
||||||
|
() => {},
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
if (active === transition) active = null;
|
||||||
|
if (!rootClass) return;
|
||||||
|
const holds = (rootClassHolds.get(rootClass) ?? 1) - 1;
|
||||||
|
if (holds > 0) {
|
||||||
|
rootClassHolds.set(rootClass, holds);
|
||||||
|
} else {
|
||||||
|
rootClassHolds.delete(rootClass);
|
||||||
|
root.classList.remove(rootClass);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+226
-61
@@ -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();
|
||||||
|
|
||||||
if (typeof document.startViewTransition === 'function') {
|
// `startViewTransition` decides whether a transition is possible at all
|
||||||
return new Promise<void>((swap) => {
|
// (support, reduced motion, one already capturing) and runs the update
|
||||||
document.startViewTransition(async () => {
|
// inline when it is not - so there is exactly one path from here down.
|
||||||
|
const underTransition = canStartViewTransition();
|
||||||
|
|
||||||
|
// Leaving the maps page: cover the iframe before the outgoing state is
|
||||||
|
// captured, so the snapshot holds a clean panel instead of a hole where
|
||||||
|
// the map was. The tick makes sure the cover is actually in the DOM by
|
||||||
|
// the time the capture reads it.
|
||||||
|
if (underTransition && navigation.from?.route?.id === MAPS_ROUTE) {
|
||||||
|
mapTransitionCover.set(true);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((swap) => {
|
||||||
|
void startViewTransition(
|
||||||
|
async () => {
|
||||||
// hand control back so SvelteKit swaps the DOM underneath the
|
// 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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,42 +344,52 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if fetchedData}
|
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
|
||||||
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
<div class="relative">
|
||||||
the page margins (padded), the graphs bleed to the edges -->
|
{#if fetchedData}
|
||||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
||||||
{#each chartDefs as def, i (i)}
|
the page margins (padded), the graphs bleed to the edges -->
|
||||||
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
<div class="mb-1 px-3 lg:px-0">
|
{#each chartDefs as def, i (i)}
|
||||||
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||||
{#if def.subtitle}
|
<div class="mb-1 px-3 lg:px-0">
|
||||||
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||||
{/if}
|
{#if def.subtitle}
|
||||||
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||||
|
<CanvasChart
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={fetchedData.timezone}
|
||||||
|
series={def.series}
|
||||||
|
bands={fetchedData.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
||||||
|
yMin={def.yMin}
|
||||||
|
yMax={def.yMax}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
{/each}
|
||||||
<CanvasChart
|
</div>
|
||||||
bind:this={chartComponents[i]}
|
{:else}
|
||||||
timestamps={timestampsSec}
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||||
timezone={fetchedData.timezone}
|
<div in:fade={{ duration: 200 }} out:skeletonOut>
|
||||||
series={def.series}
|
<ChartContainer
|
||||||
bands={fetchedData.daylightBands}
|
loading
|
||||||
unit={def.unit}
|
chartCount={params.hourly?.length || 1}
|
||||||
showCredit={def.showCredit}
|
chartHeight={340}
|
||||||
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
bleed={false}
|
||||||
yMin={def.yMin}
|
/>
|
||||||
yMax={def.yMax}
|
</div>
|
||||||
{showLegend}
|
{/if}
|
||||||
height={300}
|
</div>
|
||||||
group={CHART_GROUP}
|
|
||||||
/>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
|
||||||
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, tick, untrack } 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 { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
@@ -8,14 +9,16 @@
|
|||||||
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 {
|
import {
|
||||||
CHART_COLORS,
|
CHART_COLORS,
|
||||||
CanvasChart,
|
CanvasChart,
|
||||||
@@ -54,7 +57,9 @@
|
|||||||
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 = [
|
const COMPARE_MODEL_GROUPS = [
|
||||||
...modelGroups.filter((group) => group.value !== 'automatic_seamless'),
|
...modelGroups.filter((group) => group.value !== 'automatic_seamless'),
|
||||||
@@ -81,6 +86,10 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
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);
|
||||||
@@ -100,16 +109,17 @@
|
|||||||
showModelNames ? (isMobile ? 92 : 116) : isMobile ? 34 : 40
|
showModelNames ? (isMobile ? 92 : 116) : isMobile ? 34 : 40
|
||||||
);
|
);
|
||||||
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
|
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
|
||||||
|
const DEFAULT_MODELS = orderModels([
|
||||||
|
'ecmwf_ifs',
|
||||||
|
'meteofrance_arpege_world',
|
||||||
|
'ukmo_global_deterministic_10km',
|
||||||
|
'icon_global',
|
||||||
|
'gfs_global'
|
||||||
|
]);
|
||||||
let params = $state({
|
let params = $state({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: [...STANDARD_COMPARE_VARIABLES],
|
hourly: [...STANDARD_COMPARE_VARIABLES],
|
||||||
models: orderModels([
|
models: [...DEFAULT_MODELS]
|
||||||
'ecmwf_ifs',
|
|
||||||
'meteofrance_arpege_world',
|
|
||||||
'ukmo_global_deterministic_10km',
|
|
||||||
'icon_global',
|
|
||||||
'gfs_global'
|
|
||||||
])
|
|
||||||
});
|
});
|
||||||
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]);
|
||||||
@@ -226,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))
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -507,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) => def.series.some((series) => series.name === m.compare_model_mean()))
|
||||||
...(chartDefs.some((def) =>
|
|
||||||
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 }]
|
||||||
: [])
|
: [])
|
||||||
]);
|
]);
|
||||||
@@ -657,96 +666,103 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if selectionEmpty}
|
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
|
||||||
<div
|
<div class="relative">
|
||||||
class="rounded-xl border border-dashed border-border bg-muted/30 px-4 py-10 text-center text-sm text-muted-foreground"
|
{#if selectionEmpty}
|
||||||
>
|
|
||||||
{m.compare_empty_selection()}
|
|
||||||
</div>
|
|
||||||
{:else if noUsableData}
|
|
||||||
<div
|
|
||||||
class="rounded-xl border border-amber-300/60 bg-amber-50 px-4 py-4 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
|
|
||||||
>
|
|
||||||
{m.compare_no_data()}
|
|
||||||
</div>
|
|
||||||
{:else if loadError && !fetchedData}
|
|
||||||
<!-- The actionable error panel above replaces the chart until a retry succeeds. -->
|
|
||||||
{:else if fetchedData && chartVariablesEmpty}
|
|
||||||
<div
|
|
||||||
class="rounded-xl border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{m.compare_weather_codes_only()}
|
|
||||||
</div>
|
|
||||||
{:else if fetchedData}
|
|
||||||
<div
|
|
||||||
class="relative -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="transition-[opacity,filter] duration-200 {comparisonMuted
|
class="rounded-xl border border-dashed border-border bg-muted/30 px-4 py-10 text-center text-sm text-muted-foreground"
|
||||||
? 'pointer-events-none opacity-35 saturate-50'
|
|
||||||
: ''}"
|
|
||||||
>
|
>
|
||||||
{#each chartDefs as def, i (def.title)}
|
{m.compare_empty_selection()}
|
||||||
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
|
||||||
<div class="mb-1 px-3 lg:px-0">
|
|
||||||
<h2 class="text-sm font-bold tracking-tight">{def.title}</h2>
|
|
||||||
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
<ChartContainer
|
|
||||||
loading={loading && !fetchedData}
|
|
||||||
chartCount={1}
|
|
||||||
chartHeight={280}
|
|
||||||
minWidth={520}
|
|
||||||
bleed={false}
|
|
||||||
>
|
|
||||||
<CanvasChart
|
|
||||||
bind:this={chartComponents[i]}
|
|
||||||
timestamps={timestampsSec}
|
|
||||||
timezone={fetchedData.result.timezone}
|
|
||||||
series={def.series}
|
|
||||||
agreementStrip={def.agreementStrip}
|
|
||||||
bands={fetchedData.result.daylightBands}
|
|
||||||
unit={def.unit}
|
|
||||||
zeroBaseLeft={def.zeroBaseLeft}
|
|
||||||
yMin={def.yMin}
|
|
||||||
yMax={def.yMax}
|
|
||||||
yTicks={def.yTicks}
|
|
||||||
yTickFormat={def.yTickFormat}
|
|
||||||
{compactYAxis}
|
|
||||||
plotInsetLeft={comparisonPlotInsetLeft}
|
|
||||||
plotInsetRight={comparisonPlotInsetRight}
|
|
||||||
showCredit={i === chartDefs.length - 1}
|
|
||||||
height={280}
|
|
||||||
group={CHART_GROUP}
|
|
||||||
ariaLabel={`${def.title}. ${def.subtitle}`}
|
|
||||||
/>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{#if comparisonMuted}
|
{:else if noUsableData}
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-amber-300/60 bg-amber-50 px-4 py-4 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{m.compare_no_data()}
|
||||||
|
</div>
|
||||||
|
{:else if loadError && !fetchedData}
|
||||||
|
<!-- The actionable error panel above replaces the chart until a retry succeeds. -->
|
||||||
|
{:else if fetchedData && chartVariablesEmpty}
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{m.compare_weather_codes_only()}
|
||||||
|
</div>
|
||||||
|
{:else if fetchedData}
|
||||||
|
<div
|
||||||
|
class="relative -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 z-40 flex items-start justify-center bg-background/30 px-4 py-6 backdrop-blur-[1px]"
|
class="transition-[opacity,filter] duration-200 {comparisonMuted
|
||||||
|
? 'pointer-events-none opacity-35 saturate-50'
|
||||||
|
: ''}"
|
||||||
>
|
>
|
||||||
<div
|
{#each chartDefs as def, i (def.title)}
|
||||||
class="sticky top-24 flex flex-col items-center gap-2 rounded-xl border border-border/70 bg-background/95 p-3 text-center shadow-lg"
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||||
role="status"
|
<div class="mb-1 px-3 lg:px-0">
|
||||||
>
|
<h2 class="text-sm font-bold tracking-tight">{def.title}</h2>
|
||||||
<span class="text-xs font-semibold text-muted-foreground">
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||||
{m.compare_selection_pending()}
|
</div>
|
||||||
</span>
|
<ChartContainer
|
||||||
</div>
|
loading={loading && !fetchedData}
|
||||||
|
chartCount={1}
|
||||||
|
chartHeight={280}
|
||||||
|
minWidth={520}
|
||||||
|
bleed={false}
|
||||||
|
>
|
||||||
|
<CanvasChart
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={fetchedData.result.timezone}
|
||||||
|
series={def.series}
|
||||||
|
agreementStrip={def.agreementStrip}
|
||||||
|
bands={fetchedData.result.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
zeroBaseLeft={def.zeroBaseLeft}
|
||||||
|
yMin={def.yMin}
|
||||||
|
yMax={def.yMax}
|
||||||
|
yTicks={def.yTicks}
|
||||||
|
yTickFormat={def.yTickFormat}
|
||||||
|
{compactYAxis}
|
||||||
|
plotInsetLeft={comparisonPlotInsetLeft}
|
||||||
|
plotInsetRight={comparisonPlotInsetRight}
|
||||||
|
showCredit={i === chartDefs.length - 1}
|
||||||
|
height={280}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
ariaLabel={`${def.title}. ${def.subtitle}`}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if comparisonMuted}
|
||||||
</div>
|
<div
|
||||||
{:else}
|
class="absolute inset-0 z-40 flex items-start justify-center bg-background/30 px-4 py-6 backdrop-blur-[1px]"
|
||||||
<ChartContainer
|
>
|
||||||
loading
|
<div
|
||||||
chartCount={appliedHourly.filter((v) => v !== 'weather_code' && v !== 'cloud_cover').length || 1}
|
class="sticky top-24 flex flex-col items-center gap-2 rounded-xl border border-border/70 bg-background/95 p-3 text-center shadow-lg"
|
||||||
chartHeight={300}
|
role="status"
|
||||||
bleed={false}
|
>
|
||||||
/>
|
<span class="text-xs font-semibold text-muted-foreground">
|
||||||
{/if}
|
{m.compare_selection_pending()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||||
|
<div in:fade={{ duration: 200 }} out:skeletonOut>
|
||||||
|
<ChartContainer
|
||||||
|
loading
|
||||||
|
chartCount={appliedHourly.filter((v) => v !== 'weather_code' && v !== 'cloud_cover')
|
||||||
|
.length || 1}
|
||||||
|
chartHeight={300}
|
||||||
|
bleed={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if fetchedData && !loading && fetchedData.selection.hourly.includes('weather_code')}
|
{#if fetchedData && !loading && fetchedData.selection.hourly.includes('weather_code')}
|
||||||
<div
|
<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';
|
||||||
@@ -140,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 {
|
||||||
@@ -154,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;
|
||||||
@@ -402,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;
|
||||||
}
|
}
|
||||||
@@ -762,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)))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,40 +278,44 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if visible && months.length > 0}
|
<!-- `relative` anchors the placeholder while it fades out over the real
|
||||||
<SeasonalMonths {months} units={params} {normals} />
|
outlook rather than holding its own slot in the layout (skeletonOut). -->
|
||||||
|
<div class="relative">
|
||||||
|
{#if visible && months.length > 0}
|
||||||
|
<SeasonalMonths {months} units={params} {normals} />
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<SeasonalCharts
|
<SeasonalCharts
|
||||||
result={visible}
|
result={visible}
|
||||||
{normals}
|
{normals}
|
||||||
units={params}
|
units={params}
|
||||||
{loading}
|
{loading}
|
||||||
{showLegend}
|
{showLegend}
|
||||||
bind:charts={chartComponents}
|
bind:charts={chartComponents}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
|
||||||
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
|
|
||||||
{#snippet controls()}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
|
||||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
|
||||||
>{m.legend_show()}</Label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</ChartToolbar>
|
|
||||||
</div>
|
|
||||||
{:else if !loadError}
|
|
||||||
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
|
||||||
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each [0, 1, 2, 3, 4, 5] as i (i)}
|
|
||||||
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
|
|
||||||
</div>
|
<div class="mt-6 md:mt-10">
|
||||||
{/if}
|
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
|
||||||
</PaywallGate>
|
{#snippet controls()}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||||
|
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||||
|
>{m.legend_show()}</Label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</ChartToolbar>
|
||||||
|
</div>
|
||||||
|
{:else if !loadError}
|
||||||
|
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
|
||||||
|
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each [0, 1, 2, 3, 4, 5] as i (i)}
|
||||||
|
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</SupporterGate>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
computeDayNightWeatherCodes,
|
||||||
|
getWeatherDescription,
|
||||||
|
getWeatherIconName
|
||||||
|
} from './weather-codes';
|
||||||
|
|
||||||
|
const ICON_DIR = join(process.cwd(), 'static/images/weather-icons');
|
||||||
|
|
||||||
|
// The codes open-meteo actually emits (WMO 4677 subset), with the family each
|
||||||
|
// one belongs to. The icon mapping used to be built for a different code table,
|
||||||
|
// which is how fog ended up showing hail and a hail thunderstorm a tornado -
|
||||||
|
// mismatches nothing in the type system could catch.
|
||||||
|
const OPEN_METEO_CODES: Record<number, { meaning: string; family: RegExp }> = {
|
||||||
|
0: { meaning: 'Clear sky', family: /clear/ },
|
||||||
|
1: { meaning: 'Mainly clear', family: /clear|cloudy/ },
|
||||||
|
2: { meaning: 'Partly cloudy', family: /cloudy/ },
|
||||||
|
3: { meaning: 'Overcast', family: /cloudy/ },
|
||||||
|
45: { meaning: 'Fog', family: /fog/ },
|
||||||
|
48: { meaning: 'Depositing rime fog', family: /fog/ },
|
||||||
|
51: { meaning: 'Light drizzle', family: /sprinkle|showers/ },
|
||||||
|
53: { meaning: 'Moderate drizzle', family: /sprinkle|rain/ },
|
||||||
|
55: { meaning: 'Dense drizzle', family: /sprinkle|rain/ },
|
||||||
|
56: { meaning: 'Light freezing drizzle', family: /rain-mix|sleet/ },
|
||||||
|
57: { meaning: 'Dense freezing drizzle', family: /rain-mix|sleet/ },
|
||||||
|
61: { meaning: 'Slight rain', family: /rain|sprinkle|showers/ },
|
||||||
|
63: { meaning: 'Moderate rain', family: /rain/ },
|
||||||
|
65: { meaning: 'Heavy rain', family: /rain/ },
|
||||||
|
66: { meaning: 'Light freezing rain', family: /rain-mix|sleet/ },
|
||||||
|
67: { meaning: 'Heavy freezing rain', family: /rain-mix|sleet/ },
|
||||||
|
71: { meaning: 'Slight snowfall', family: /snow/ },
|
||||||
|
73: { meaning: 'Moderate snowfall', family: /snow/ },
|
||||||
|
75: { meaning: 'Heavy snowfall', family: /snow/ },
|
||||||
|
77: { meaning: 'Snow grains', family: /snow/ },
|
||||||
|
80: { meaning: 'Slight rain showers', family: /showers|rain/ },
|
||||||
|
81: { meaning: 'Moderate rain showers', family: /showers|rain/ },
|
||||||
|
82: { meaning: 'Violent rain showers', family: /showers|rain/ },
|
||||||
|
85: { meaning: 'Slight snow showers', family: /snow/ },
|
||||||
|
86: { meaning: 'Heavy snow showers', family: /snow/ },
|
||||||
|
95: { meaning: 'Thunderstorm', family: /thunderstorm|storm-showers|lightning/ },
|
||||||
|
96: { meaning: 'Thunderstorm with slight hail', family: /thunderstorm|storm-showers|hail/ },
|
||||||
|
99: { meaning: 'Thunderstorm with heavy hail', family: /thunderstorm|storm-showers|hail/ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const codes = Object.keys(OPEN_METEO_CODES).map(Number);
|
||||||
|
|
||||||
|
describe('getWeatherIconName', () => {
|
||||||
|
it.each(codes)('code %i resolves to icon files that exist', (code) => {
|
||||||
|
for (const daytime of [true, false]) {
|
||||||
|
const name = getWeatherIconName(code, daytime);
|
||||||
|
expect(existsSync(join(ICON_DIR, `${name}.svg`)), `${name}.svg missing`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(codes)('code %i uses a glyph that matches its meaning', (code) => {
|
||||||
|
const { meaning, family } = OPEN_METEO_CODES[code];
|
||||||
|
for (const daytime of [true, false]) {
|
||||||
|
const name = getWeatherIconName(code, daytime);
|
||||||
|
expect(name, `${code} (${meaning}) → ${name}`).toMatch(family);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never shows a tornado: open-meteo has no tornado code', () => {
|
||||||
|
const names = codes.flatMap((c) => [getWeatherIconName(c, true), getWeatherIconName(c, false)]);
|
||||||
|
expect(names.some((n) => n.includes('tornado'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes overcast from partly cloudy', () => {
|
||||||
|
expect(getWeatherIconName(3, true)).not.toBe(getWeatherIconName(2, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a clear glyph for codes it does not know', () => {
|
||||||
|
expect(getWeatherIconName(12345, true)).toBe('wi-day-clear');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWeatherDescription', () => {
|
||||||
|
it.each(codes)('code %i has a description', (code) => {
|
||||||
|
expect(getWeatherDescription(code).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for unknown or missing codes', () => {
|
||||||
|
expect(getWeatherDescription(12345)).toBe('');
|
||||||
|
expect(getWeatherDescription(null)).toBe('');
|
||||||
|
expect(getWeatherDescription(undefined)).toBe('');
|
||||||
|
expect(getWeatherDescription(NaN)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('daily aggregation', () => {
|
||||||
|
const DAY = 24 * 3600;
|
||||||
|
// One synthetic day: sunrise 06:00, sunset 20:00, hourly codes from 00:00.
|
||||||
|
const hourly = (codes: number[]) => codes.map((_, i) => i * 3600 * 1000);
|
||||||
|
const run = (hourCodes: number[]) =>
|
||||||
|
computeDayNightWeatherCodes(hourly(hourCodes), hourCodes, [6 * 3600], [20 * 3600]).day[0];
|
||||||
|
|
||||||
|
const mostlyClear = (overrides: Record<number, number>) =>
|
||||||
|
Array.from({ length: DAY / 3600 }, (_, h) => overrides[h] ?? 0);
|
||||||
|
|
||||||
|
it('lets a single thundery hour lead the day, as before', () => {
|
||||||
|
expect(run(mostlyClear({ 18: 95 }))).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the plain thunderstorm when a lone 99 ties with a 95', () => {
|
||||||
|
// the reported case: one hour of 99 next to one hour of 95 used to escalate
|
||||||
|
// the whole day card to the most extreme code on the scale
|
||||||
|
expect(run(mostlyClear({ 18: 99, 19: 95 }))).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still shows 99 when it is the only thunder code', () => {
|
||||||
|
expect(run(mostlyClear({ 18: 99 }))).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows frequency before severity within thunder', () => {
|
||||||
|
expect(run(mostlyClear({ 15: 99, 16: 99, 17: 95 }))).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps preferring the heavier code on a tie outside thunder', () => {
|
||||||
|
// 65 (heavy rain) over 80 (slight showers) - the open-meteo max-code flaw
|
||||||
|
expect(run(mostlyClear({ 12: 65, 13: 80 }))).toBe(65);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let one foggy hour brand the day', () => {
|
||||||
|
expect(run(mostlyClear({ 7: 45 }))).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,85 +474,115 @@
|
|||||||
{locationRoute}
|
{locationRoute}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if fetchedHourly && fetchedDaily}
|
<!-- `relative` is what lets the placeholder fade out on top of the table
|
||||||
<div class="day-region-table" use:daySwap={selectedDayKey}>
|
instead of holding a second slot in the layout (see skeletonOut). -->
|
||||||
<HourlyTable
|
<div class="relative">
|
||||||
data={fetchedHourly}
|
{#if fetchedHourly && fetchedDaily}
|
||||||
daily={fetchedDaily}
|
<div class="day-region-table" use:daySwap={selectedDayKey}>
|
||||||
{selectedDay}
|
<HourlyTable
|
||||||
units={params}
|
data={fetchedHourly}
|
||||||
locationName={location.name ?? ''}
|
daily={fetchedDaily}
|
||||||
onCustomize={() => (variableSidebarOpen = true)}
|
{selectedDay}
|
||||||
/>
|
units={params}
|
||||||
</div>
|
locationName={location.name ?? ''}
|
||||||
{:else}
|
onCustomize={() => (variableSidebarOpen = true)}
|
||||||
<!-- Mirrors the real table: same header bar and the same body height,
|
/>
|
||||||
so the heading doesn't pop in and nothing below moves.
|
</div>
|
||||||
Placeholders only fade IN - a fade-out would keep them in the
|
{:else}
|
||||||
layout while the real content mounts below, and everything below
|
<!-- Mirrors the real table: same header bar and the same body height,
|
||||||
would jump the moment they finally unmount. -->
|
so the heading doesn't pop in and nothing below moves. -->
|
||||||
<div
|
|
||||||
in:fade={{ duration: 200 }}
|
|
||||||
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
|
in:fade={{ duration: 200 }}
|
||||||
|
out:skeletonOut
|
||||||
|
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||||||
>
|
>
|
||||||
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
|
<div
|
||||||
<div class="flex items-center gap-2">
|
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
|
||||||
<div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div>
|
>
|
||||||
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
|
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
</div>
|
<div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div>
|
||||||
<!-- one placeholder per row the real table will render, so the body
|
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
|
||||||
reads as a loading table rather than a blank panel -->
|
|
||||||
<div class="divide-y divide-border/50">
|
|
||||||
{#each { length: enabledTableRows } as _, i (i)}
|
|
||||||
<div class="flex items-center gap-4 px-4" style="height: {TABLE_ROW_PX}px">
|
|
||||||
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
|
||||||
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
<!-- one placeholder per row the real table will render, so the body
|
||||||
</div>
|
reads as a loading table rather than a blank panel -->
|
||||||
{/if}
|
<div class="divide-y divide-border/50">
|
||||||
|
<!-- the time + daylight row, which is taller than the variable rows -->
|
||||||
{#if fetchedHourly && fetchedDaily}
|
<div class="flex items-center gap-4 px-4" style="height: {TABLE_TIME_ROW_PX}px">
|
||||||
<div class="day-region-summary" use:daySwap={selectedDayKey}>
|
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
||||||
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
|
<div class="h-5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{#each { length: enabledTableRows } as _, i (i)}
|
||||||
<!-- same footprint as the written forecast, so it doesn't shove the
|
<div class="flex items-center gap-4 px-4" style="height: {tableRowPx}px">
|
||||||
meteograms down when it arrives -->
|
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
||||||
<section class="mt-6" in:fade={{ duration: 200 }}>
|
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
||||||
<div class="h-52 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-40"></div>
|
</div>
|
||||||
</section>
|
{/each}
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if fetchedHourly}
|
|
||||||
<div class="day-region-charts" use:daySwap={selectedDayKey}>
|
|
||||||
<MeteogramCharts
|
|
||||||
data={fetchedHourly}
|
|
||||||
{selectedDay}
|
|
||||||
units={params}
|
|
||||||
{loading}
|
|
||||||
{chartHeight}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- reserve the exact chart area height before the first fetch resolves,
|
|
||||||
header row included -->
|
|
||||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
|
||||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
|
|
||||||
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
|
{/if}
|
||||||
</section>
|
</div>
|
||||||
{/if}
|
|
||||||
|
<div class="relative">
|
||||||
|
{#if fetchedHourly && fetchedDaily}
|
||||||
|
<div class="day-region-summary" use:daySwap={selectedDayKey}>
|
||||||
|
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Same footprint as the written forecast, so it doesn't shove the
|
||||||
|
meteograms down when it arrives. The heights are measured from the
|
||||||
|
real card at each breakpoint: on phones the narrative is clamped to
|
||||||
|
five lines with a fixed toggle row, so that side is exact; from md
|
||||||
|
up the text runs free and this is the typical height. The sun/moon
|
||||||
|
grid reflows at sm, md and lg, which is why all four are needed. -->
|
||||||
|
<section class="mt-6" in:fade={{ duration: 200 }} out:skeletonOut>
|
||||||
|
<div
|
||||||
|
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
|
||||||
|
></div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
{#if fetchedHourly}
|
||||||
|
<div class="day-region-charts" use:daySwap={selectedDayKey}>
|
||||||
|
<MeteogramCharts
|
||||||
|
data={fetchedHourly}
|
||||||
|
{selectedDay}
|
||||||
|
units={params}
|
||||||
|
{loading}
|
||||||
|
{chartHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- reserve the exact chart area height before the first fetch resolves,
|
||||||
|
header row included -->
|
||||||
|
<section class="mt-8" in:fade={{ duration: 200 }} out:skeletonOut>
|
||||||
|
<!-- The real header wraps to two rows until the controls fit beside
|
||||||
|
the title, which happens at different widths than you would
|
||||||
|
expect because the sidebar takes its share from md up. These
|
||||||
|
min-heights follow the measured wrap points. -->
|
||||||
|
<div
|
||||||
|
class="mb-3 flex min-h-27 flex-wrap items-center justify-between gap-2 sm:min-h-16.5 md:min-h-27 lg:min-h-16.5 xl:min-h-7.5"
|
||||||
|
>
|
||||||
|
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
|
||||||
|
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Each meteogram is its plot plus a title row and axis labels, so
|
||||||
|
the reserved box needs that chrome on top of the plot height or
|
||||||
|
everything below lands ~50px per chart too high. -->
|
||||||
|
<ChartContainer
|
||||||
|
loading
|
||||||
|
chartCount={enabledChartCount || 1}
|
||||||
|
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if selectedDayKey}
|
{#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,15 +144,35 @@
|
|||||||
</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">
|
||||||
{#if sentences.length > 0}
|
<div class="narrative">
|
||||||
<p class="text-[15px] leading-relaxed text-foreground">
|
{#if sentences.length > 0}
|
||||||
{sentences.join(' ')}
|
<p
|
||||||
</p>
|
bind:this={textEl}
|
||||||
{:else}
|
class="text-[15px] leading-relaxed text-foreground"
|
||||||
<p class="text-[15px] leading-relaxed text-muted-foreground">
|
class:clamped={!expanded}
|
||||||
{m.summary_no_data()}
|
>
|
||||||
</p>
|
{sentences.join(' ')}
|
||||||
{/if}
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[15px] leading-relaxed text-muted-foreground">
|
||||||
|
{m.summary_no_data()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Always occupies its row on phones, even when the text fits: a
|
||||||
|
toggle that appears only sometimes is itself a layout shift. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle mt-1 cursor-pointer text-[13px] font-semibold text-primary hover:underline"
|
||||||
|
class:invisible={!overflows && !expanded}
|
||||||
|
aria-hidden={!overflows && !expanded}
|
||||||
|
tabindex={!overflows && !expanded ? -1 : 0}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
>
|
||||||
|
{expanded ? m.summary_read_less() : m.summary_read_more()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
|
<!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
|
||||||
<dl
|
<dl
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user