Compare commits
24
Commits
bde815c35a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2253d002d | ||
|
|
f186c3ff9a | ||
|
|
3f9f1df8f0 | ||
|
|
032b652675 | ||
|
|
83b0e46e64 | ||
|
|
d0f94094ca | ||
|
|
810f9c2605 | ||
|
|
c4a74ba91c | ||
|
|
c3bb6de49c | ||
|
|
1c5ea2b52e | ||
|
|
f4a02a208c | ||
|
|
506831cf88 | ||
|
|
39f36819ae | ||
|
|
b53eb77c75 | ||
|
|
b50eee634e | ||
|
|
025623aaa6 | ||
|
|
dbfa38f6c5 | ||
|
|
9fc17e7417 | ||
|
|
f66e259e32 | ||
|
|
8b58bc01d7 | ||
|
|
edee9fe33d | ||
|
|
5541df20e1 | ||
|
|
77af339df4 | ||
|
|
ea9eb17d75 |
+13
@@ -33,3 +33,16 @@ AGENTS.md
|
||||
|
||||
# Claude Code scratch: throwaway probe scripts, never committed
|
||||
/.scratch
|
||||
|
||||
# Empty stubs the Claude Code sandbox mounts over shell/editor dotfiles while it
|
||||
# runs. They are not project files and keep sneaking into commits via `git add -A`.
|
||||
/.bash_profile
|
||||
/.bashrc
|
||||
/.profile
|
||||
/.zprofile
|
||||
/.zshrc
|
||||
/.gitconfig
|
||||
/.gitmodules
|
||||
/.ripgreprc
|
||||
/.idea
|
||||
/.mcp.json
|
||||
|
||||
@@ -46,6 +46,12 @@ Pages that are not prerendered (unlisted cities, GPS coordinate routes like
|
||||
and resolves the location client-side. Configure the server to serve
|
||||
`404.html` for unknown paths.
|
||||
|
||||
Serve it as an **internal rewrite (200)**, not as an error page. `error_page
|
||||
404 /404.html` sends the right body with a 404 status: the page works, but
|
||||
every hard reload of an unprerendered URL logs a 404 in the network panel and
|
||||
tells crawlers the page does not exist. `try_files` with a URI as its last
|
||||
argument does an internal redirect instead, and answers 200.
|
||||
|
||||
### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
|
||||
|
||||
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
|
||||
@@ -83,11 +89,12 @@ drizzli.example.com {
|
||||
server {
|
||||
server_name drizzli.example.com;
|
||||
root /srv/drizzli;
|
||||
error_page 404 /404.html;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
# the trailing /404.html is a URI, so nginx rewrites internally and
|
||||
# answers 200 - `error_page 404 /404.html` would answer 404 instead
|
||||
try_files $uri $uri/index.html /404.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+88
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "7-Tage-Vorhersage",
|
||||
"nav_week": "Wochenvorhersage",
|
||||
"nav_compare": "Modellvergleich",
|
||||
"nav_14day": "14-Tage-Vorhersage",
|
||||
"nav_seasonal": "Saisonal",
|
||||
@@ -44,7 +44,7 @@
|
||||
"day_today": "Heute",
|
||||
"day_tomorrow": "Morgen",
|
||||
"day_yesterday": "Gestern",
|
||||
"page_week_subtitle": "7-Tage-Vorhersage",
|
||||
"page_week_subtitle": "Wochenvorhersage",
|
||||
"page_compare_subtitle": "Modellvergleich",
|
||||
"page_14day_subtitle": "14-Tage-Ensemblevorhersage",
|
||||
"page_seasonal_subtitle": "Saisonale Aussichten",
|
||||
@@ -101,7 +101,7 @@
|
||||
"period_morning": "morgens",
|
||||
"period_afternoon": "nachmittags",
|
||||
"period_evening": "in den Abendstunden",
|
||||
"footer_tagline": "Schnelle, unkomplizierte Wettervorhersagen auf Basis offener Daten.",
|
||||
"footer_tagline": "Schnelle, gratis, unkomplizierte Wettervorhersagen auf Basis offener Daten.",
|
||||
"footer_data_by": "Wetterdaten von",
|
||||
"footer_forecasts": "Vorhersagen",
|
||||
"footer_popular": "Beliebte Orte",
|
||||
@@ -260,6 +260,8 @@
|
||||
"table_interval_aria": "Stundenintervall",
|
||||
"table_now": "Jetzt",
|
||||
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
|
||||
"page_loading": "Wird geladen…",
|
||||
"page_loading_dismiss": "Schließen",
|
||||
"charts_loading": "Diagramme werden geladen…",
|
||||
"chart_download": "Meteogramm als PNG-Bild herunterladen",
|
||||
"chart_credit_viz": "Visualisierung von",
|
||||
@@ -267,7 +269,7 @@
|
||||
"legend_show": "Legende anzeigen",
|
||||
"meteograms_none_before": "Keine Meteogramme konfiguriert,",
|
||||
"meteograms_none_action": "fügen Sie Variablen hinzu",
|
||||
"meteograms_none_historical": "Keine Meteogramme konfiguriert. Fügen Sie Variablen auf der 7-Tage-Seite hinzu.",
|
||||
"meteograms_none_historical": "Keine Meteogramme konfiguriert. Fügen Sie Variablen auf der Wochenvorhersage-Seite hinzu.",
|
||||
"search_searching": "Wird gesucht…",
|
||||
"search_favorites": "Favoriten",
|
||||
"search_recent": "Zuletzt",
|
||||
@@ -280,9 +282,60 @@
|
||||
"model_automatic_selection": "Automatische Auswahl",
|
||||
"model_selector_aria": "Auswahl: {label}",
|
||||
"compare_models_heading": "Modelle",
|
||||
"compare_models_choose": "Modelle für den Vergleich wählen",
|
||||
"compare_variables_heading": "Stündliche Wettervariablen",
|
||||
"compare_standard_preset": "Standardvergleich",
|
||||
"compare_weather_conditions": "Wetterlage & Gesamtbewölkung",
|
||||
"compare_wind_direction_height": "Windrichtung in {height} m",
|
||||
"compare_standard_preset_description": "Ein ausgewogener Ausgangspunkt für den Vergleich von Vorhersagemodellen.",
|
||||
"compare_preset_active": "Aktiv",
|
||||
"compare_custom_selection": "Benutzerdefiniert · {count} ausgewählt",
|
||||
"compare_customize_variables": "Variablen anpassen",
|
||||
"compare_restore_defaults": "Standard wiederherstellen",
|
||||
"compare_variables_pending": "Variablenauswahl geändert",
|
||||
"compare_selected_models": "Ausgewählte Modelle",
|
||||
"compare_selection_pending": "Auswahl geändert",
|
||||
"compare_apply_selection": "Anwenden & Diagramme neu laden",
|
||||
"compare_discard_changes": "Änderungen verwerfen",
|
||||
"compare_customize_models": "Modelle anpassen",
|
||||
"compare_edit_models": "Modelle bearbeiten",
|
||||
"compare_edit_variables": "Variablen bearbeiten",
|
||||
"compare_edit_models_description": "Modelle suchen oder ganze Anbietergruppen auswählen.",
|
||||
"compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.",
|
||||
"compare_search_models": "Modelle suchen",
|
||||
"compare_search_variables": "Variablen suchen",
|
||||
"compare_show_model_names": "Modellnamen anzeigen",
|
||||
"compare_hide_model_names": "Modellnamen ausblenden",
|
||||
"compare_only_selected": "Nur ausgewählte",
|
||||
"compare_select_group": "Alle auswählen",
|
||||
"compare_clear_group": "Alle abwählen",
|
||||
"compare_no_matching_options": "Keine passenden Optionen.",
|
||||
"compare_no_models_selected": "Keine Modelle ausgewählt.",
|
||||
"compare_remove_model": "{model} entfernen",
|
||||
"compare_timeline_title": "Zeitleiste des Modellvergleichs",
|
||||
"compare_model_label": "Modell",
|
||||
"page_compare_title": "Wettermodelle vergleichen",
|
||||
"page_compare_description": "Stündliche Vorhersagen mehrerer Wettermodelle für jeden Ort vergleichen.",
|
||||
"compare_direction_subtitle": "Richtungsstreuung über {count} Modelle · kein Mittelwert",
|
||||
"compare_scalar_subtitle": "Über {count} Modelle · gestrichelt = Modellmittel",
|
||||
"compare_precipitation_subtitle": "Über {count} Modelle · unterer Streifen = Niederschlagsübereinstimmung",
|
||||
"compare_precipitation_agreement": "Übereinstimmung",
|
||||
"compare_precipitation_agreement_tooltip": "{wet}/{available} mit Niederschlag · Median {median} {unit} · Spanne {min}–{max} {unit}",
|
||||
"compare_model_mean": "Modellmittel",
|
||||
"compare_showing_previous": "Der vorherige erfolgreiche Vergleich bleibt sichtbar.",
|
||||
"compare_empty_selection": "Mindestens ein Modell und eine Wettervariable auswählen.",
|
||||
"compare_no_data": "Keines der ausgewählten Modelle liefert für diesen Ort und diese Auswahl nutzbare Daten.",
|
||||
"compare_weather_codes_only": "Wetterlage und Gesamtbewölkung erscheinen unten in der Modellzeitleiste. Für ein Diagramm eine weitere Variable auswählen.",
|
||||
"compare_model_colors": "Modellfarben",
|
||||
"compare_group_temperature": "Temperatur & Feuchte",
|
||||
"compare_group_precipitation": "Niederschlag & Wetter",
|
||||
"compare_group_clouds": "Druck & Wolken",
|
||||
"compare_group_wind": "Wind & Atmosphäre",
|
||||
"compare_group_upper_air": "Temperatur in der Höhe",
|
||||
"compare_many_models_warning": "{count} Modelle sind ausgewählt. Der Vergleich kann langsamer und die Farben schwerer unterscheidbar sein.",
|
||||
"compare_timeline_hint": "Wetter nach Modell; der Piktogramm-Hintergrund zeigt die Gesamtbewölkung",
|
||||
"compare_timeline_scroll_aria": "Scrollbare Wetterzeitleiste der Modelle",
|
||||
"compare_timeline_caption": "Stündliche Wetterbedingungen nach Vorhersagemodell",
|
||||
"ensemble_trimmed": "Das Ensemble dieses Modells reicht nur etwa {days} Tage voraus, die Streuung ist auf den verfügbaren Zeitraum gekürzt.",
|
||||
"seasonal_explainer_before": "Eine saisonale Vorhersage zeigt, wie stark ein ganzer Monat voraussichtlich",
|
||||
"seasonal_explainer_strong": "von seiner Klimanormalen abweicht",
|
||||
@@ -335,5 +388,35 @@
|
||||
"cadence_varies": "variiert",
|
||||
"model_group_automatic": "Automatisch",
|
||||
"model_group_reanalysis": "ECMWF-Reanalyse",
|
||||
"model_group_regional_reanalysis": "Regionale Reanalyse"
|
||||
"model_group_regional_reanalysis": "Regionale Reanalyse",
|
||||
"wmo_0": "Klarer Himmel",
|
||||
"wmo_1": "Überwiegend klar",
|
||||
"wmo_2": "Teils bewölkt",
|
||||
"wmo_3": "Bedeckt",
|
||||
"wmo_45": "Nebel",
|
||||
"wmo_48": "Gefrierender Nebel",
|
||||
"wmo_51": "Leichter Nieselregen",
|
||||
"wmo_53": "Mäßiger Nieselregen",
|
||||
"wmo_55": "Starker Nieselregen",
|
||||
"wmo_56": "Leichter gefrierender Nieselregen",
|
||||
"wmo_57": "Starker gefrierender Nieselregen",
|
||||
"wmo_61": "Leichter Regen",
|
||||
"wmo_63": "Mäßiger Regen",
|
||||
"wmo_65": "Starker Regen",
|
||||
"wmo_66": "Leichter gefrierender Regen",
|
||||
"wmo_67": "Starker gefrierender Regen",
|
||||
"wmo_71": "Leichter Schneefall",
|
||||
"wmo_73": "Mäßiger Schneefall",
|
||||
"wmo_75": "Starker Schneefall",
|
||||
"wmo_77": "Schneegriesel",
|
||||
"wmo_80": "Leichte Regenschauer",
|
||||
"wmo_81": "Mäßige Regenschauer",
|
||||
"wmo_82": "Heftige Regenschauer",
|
||||
"wmo_85": "Leichte Schneeschauer",
|
||||
"wmo_86": "Starke Schneeschauer",
|
||||
"wmo_95": "Gewitter",
|
||||
"wmo_96": "Gewitter mit leichtem Hagel",
|
||||
"wmo_99": "Gewitter mit starkem Hagel",
|
||||
"summary_read_more": "Mehr anzeigen",
|
||||
"summary_read_less": "Weniger anzeigen"
|
||||
}
|
||||
|
||||
+88
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "7-Day Forecast",
|
||||
"nav_week": "Weekly Forecast",
|
||||
"nav_compare": "Model Comparison",
|
||||
"nav_14day": "14-Day Forecast",
|
||||
"nav_seasonal": "Seasonal",
|
||||
@@ -44,7 +44,7 @@
|
||||
"day_today": "Today",
|
||||
"day_tomorrow": "Tomorrow",
|
||||
"day_yesterday": "Yesterday",
|
||||
"page_week_subtitle": "7-day forecast",
|
||||
"page_week_subtitle": "Weekly forecast",
|
||||
"page_compare_subtitle": "Model comparison",
|
||||
"page_14day_subtitle": "14-day ensemble forecast",
|
||||
"page_seasonal_subtitle": "Seasonal outlook",
|
||||
@@ -101,7 +101,7 @@
|
||||
"period_morning": "in the morning",
|
||||
"period_afternoon": "in the afternoon",
|
||||
"period_evening": "in the evening",
|
||||
"footer_tagline": "Fast, no-nonsense weather forecasts built on open data.",
|
||||
"footer_tagline": "Fast, free, no-nonsense weather forecasts built on open data.",
|
||||
"footer_data_by": "Weather data by",
|
||||
"footer_forecasts": "Forecasts",
|
||||
"footer_popular": "Popular locations",
|
||||
@@ -260,6 +260,8 @@
|
||||
"table_interval_aria": "Hourly interval",
|
||||
"table_now": "Now",
|
||||
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
|
||||
"page_loading": "Loading…",
|
||||
"page_loading_dismiss": "Dismiss",
|
||||
"charts_loading": "Loading charts...",
|
||||
"chart_download": "Download meteogram as PNG image",
|
||||
"chart_credit_viz": "visualisation by",
|
||||
@@ -267,7 +269,7 @@
|
||||
"legend_show": "Show legend",
|
||||
"meteograms_none_before": "No meteograms configured,",
|
||||
"meteograms_none_action": "add some variables",
|
||||
"meteograms_none_historical": "No meteograms configured. Add variables from the 7-day forecast page.",
|
||||
"meteograms_none_historical": "No meteograms configured. Add variables from the weekly forecast page.",
|
||||
"search_searching": "Searching...",
|
||||
"search_favorites": "Favorites",
|
||||
"search_recent": "Recent",
|
||||
@@ -280,9 +282,60 @@
|
||||
"model_automatic_selection": "Automatic selection",
|
||||
"model_selector_aria": "{label} selection",
|
||||
"compare_models_heading": "Models",
|
||||
"compare_models_choose": "Choose which models to plot",
|
||||
"compare_variables_heading": "Hourly Weather Variables",
|
||||
"compare_standard_preset": "Standard comparison",
|
||||
"compare_weather_conditions": "Weather conditions & cloud cover",
|
||||
"compare_wind_direction_height": "Wind direction at {height} m",
|
||||
"compare_standard_preset_description": "A balanced starting point for comparing forecast models.",
|
||||
"compare_preset_active": "Active",
|
||||
"compare_custom_selection": "Custom · {count} selected",
|
||||
"compare_customize_variables": "Customize variables",
|
||||
"compare_restore_defaults": "Restore defaults",
|
||||
"compare_variables_pending": "Variable selection changed",
|
||||
"compare_selected_models": "Selected models",
|
||||
"compare_selection_pending": "Selection changed",
|
||||
"compare_apply_selection": "Apply & reload charts",
|
||||
"compare_discard_changes": "Discard changes",
|
||||
"compare_customize_models": "Customize models",
|
||||
"compare_edit_models": "Edit models",
|
||||
"compare_edit_variables": "Edit variables",
|
||||
"compare_edit_models_description": "Search models or select complete provider families.",
|
||||
"compare_edit_variables_description": "Search variables or select complete thematic groups.",
|
||||
"compare_search_models": "Search models",
|
||||
"compare_search_variables": "Search variables",
|
||||
"compare_show_model_names": "Show model names",
|
||||
"compare_hide_model_names": "Hide model names",
|
||||
"compare_only_selected": "Only selected",
|
||||
"compare_select_group": "Select all",
|
||||
"compare_clear_group": "Clear all",
|
||||
"compare_no_matching_options": "No matching options.",
|
||||
"compare_no_models_selected": "No models selected.",
|
||||
"compare_remove_model": "Remove {model}",
|
||||
"compare_timeline_title": "Model Comparison Timeline",
|
||||
"compare_model_label": "Model",
|
||||
"page_compare_title": "Weather model comparison",
|
||||
"page_compare_description": "Compare hourly forecasts from multiple weather models for any location.",
|
||||
"compare_direction_subtitle": "Direction spread across {count} models · no mean",
|
||||
"compare_scalar_subtitle": "Across {count} models · dashed = model mean",
|
||||
"compare_precipitation_subtitle": "Across {count} models · lower strip = precipitation agreement",
|
||||
"compare_precipitation_agreement": "Agreement",
|
||||
"compare_precipitation_agreement_tooltip": "{wet}/{available} wet · median {median} {unit} · range {min}–{max} {unit}",
|
||||
"compare_model_mean": "Model mean",
|
||||
"compare_showing_previous": "The previous successful comparison remains visible.",
|
||||
"compare_empty_selection": "Select at least one model and one weather variable to build a comparison.",
|
||||
"compare_no_data": "None of the selected models provides usable data for this location and selection.",
|
||||
"compare_weather_codes_only": "Weather conditions and cloud cover are shown in the model timeline below. Select another variable to add a chart.",
|
||||
"compare_model_colors": "Model colors",
|
||||
"compare_group_temperature": "Temperature & humidity",
|
||||
"compare_group_precipitation": "Precipitation & conditions",
|
||||
"compare_group_clouds": "Pressure & clouds",
|
||||
"compare_group_wind": "Wind & atmosphere",
|
||||
"compare_group_upper_air": "Upper-air temperature",
|
||||
"compare_many_models_warning": "{count} models are selected. The comparison may be slower and individual colors harder to distinguish.",
|
||||
"compare_timeline_hint": "Conditions by model; pictogram backgrounds show total cloud cover",
|
||||
"compare_timeline_scroll_aria": "Scrollable model weather timeline",
|
||||
"compare_timeline_caption": "Hourly weather conditions by forecast model",
|
||||
"ensemble_trimmed": "This model's ensemble only reaches about {days} days ahead, the spread is trimmed to its available range.",
|
||||
"seasonal_explainer_before": "A seasonal forecast shows how a whole month is likely to",
|
||||
"seasonal_explainer_strong": "depart from its climate normal",
|
||||
@@ -335,5 +388,35 @@
|
||||
"cadence_varies": "varies",
|
||||
"model_group_automatic": "Automatic",
|
||||
"model_group_reanalysis": "ECMWF reanalysis",
|
||||
"model_group_regional_reanalysis": "Regional reanalysis"
|
||||
"model_group_regional_reanalysis": "Regional reanalysis",
|
||||
"wmo_0": "Clear sky",
|
||||
"wmo_1": "Mainly clear",
|
||||
"wmo_2": "Partly cloudy",
|
||||
"wmo_3": "Overcast",
|
||||
"wmo_45": "Fog",
|
||||
"wmo_48": "Depositing rime fog",
|
||||
"wmo_51": "Light drizzle",
|
||||
"wmo_53": "Moderate drizzle",
|
||||
"wmo_55": "Dense drizzle",
|
||||
"wmo_56": "Light freezing drizzle",
|
||||
"wmo_57": "Dense freezing drizzle",
|
||||
"wmo_61": "Slight rain",
|
||||
"wmo_63": "Moderate rain",
|
||||
"wmo_65": "Heavy rain",
|
||||
"wmo_66": "Light freezing rain",
|
||||
"wmo_67": "Heavy freezing rain",
|
||||
"wmo_71": "Slight snowfall",
|
||||
"wmo_73": "Moderate snowfall",
|
||||
"wmo_75": "Heavy snowfall",
|
||||
"wmo_77": "Snow grains",
|
||||
"wmo_80": "Slight rain showers",
|
||||
"wmo_81": "Moderate rain showers",
|
||||
"wmo_82": "Violent rain showers",
|
||||
"wmo_85": "Slight snow showers",
|
||||
"wmo_86": "Heavy snow showers",
|
||||
"wmo_95": "Thunderstorm",
|
||||
"wmo_96": "Thunderstorm with slight hail",
|
||||
"wmo_99": "Thunderstorm with heavy hail",
|
||||
"summary_read_more": "Read more",
|
||||
"summary_read_less": "Show less"
|
||||
}
|
||||
|
||||
+91
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "Pronóstico de 7 días",
|
||||
"nav_week": "Pronóstico semanal",
|
||||
"nav_compare": "Comparación de modelos",
|
||||
"nav_14day": "Pronóstico de 14 días",
|
||||
"nav_seasonal": "Estacional",
|
||||
@@ -44,11 +44,11 @@
|
||||
"day_today": "Hoy",
|
||||
"day_tomorrow": "Mañana",
|
||||
"day_yesterday": "Ayer",
|
||||
"page_week_subtitle": "pronóstico de 7 días",
|
||||
"page_compare_subtitle": "comparación de modelos",
|
||||
"page_14day_subtitle": "pronóstico de conjunto a 14 días",
|
||||
"page_seasonal_subtitle": "previsión estacional",
|
||||
"page_historical_subtitle": "clima histórico",
|
||||
"page_week_subtitle": "Pronóstico semanal",
|
||||
"page_compare_subtitle": "Comparación de modelos",
|
||||
"page_14day_subtitle": "Pronóstico de conjunto a 14 días",
|
||||
"page_seasonal_subtitle": "Previsión estacional",
|
||||
"page_historical_subtitle": "Clima histórico",
|
||||
"hourly_heading": "por horas",
|
||||
"hourly_variables": "Variables",
|
||||
"meteograms_heading": "Meteogramas",
|
||||
@@ -260,6 +260,8 @@
|
||||
"table_interval_aria": "Intervalo horario",
|
||||
"table_now": "Ahora",
|
||||
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
|
||||
"page_loading": "Cargando…",
|
||||
"page_loading_dismiss": "Descartar",
|
||||
"charts_loading": "Cargando gráficos…",
|
||||
"chart_download": "Descargar el meteograma como imagen PNG",
|
||||
"chart_credit_viz": "visualización de",
|
||||
@@ -267,7 +269,7 @@
|
||||
"legend_show": "Mostrar leyenda",
|
||||
"meteograms_none_before": "No hay meteogramas configurados,",
|
||||
"meteograms_none_action": "añade algunas variables",
|
||||
"meteograms_none_historical": "No hay meteogramas configurados. Añade variables desde la página de 7 días.",
|
||||
"meteograms_none_historical": "No hay meteogramas configurados. Añade variables desde la página del pronóstico semanal.",
|
||||
"search_searching": "Buscando…",
|
||||
"search_favorites": "Favoritos",
|
||||
"search_recent": "Recientes",
|
||||
@@ -280,9 +282,60 @@
|
||||
"model_automatic_selection": "Selección automática",
|
||||
"model_selector_aria": "Selección de {label}",
|
||||
"compare_models_heading": "Modelos",
|
||||
"compare_models_choose": "Elige los modelos a comparar",
|
||||
"compare_variables_heading": "Variables meteorológicas horarias",
|
||||
"compare_standard_preset": "Comparación estándar",
|
||||
"compare_weather_conditions": "Condiciones y nubosidad",
|
||||
"compare_wind_direction_height": "Dirección del viento a {height} m",
|
||||
"compare_standard_preset_description": "Un punto de partida equilibrado para comparar modelos de previsión.",
|
||||
"compare_preset_active": "Activa",
|
||||
"compare_custom_selection": "Personalizada · {count} seleccionadas",
|
||||
"compare_customize_variables": "Personalizar variables",
|
||||
"compare_restore_defaults": "Restablecer selección estándar",
|
||||
"compare_variables_pending": "Selección de variables modificada",
|
||||
"compare_selected_models": "Modelos seleccionados",
|
||||
"compare_selection_pending": "Selección modificada",
|
||||
"compare_apply_selection": "Aplicar y recargar gráficos",
|
||||
"compare_discard_changes": "Descartar cambios",
|
||||
"compare_customize_models": "Personalizar modelos",
|
||||
"compare_edit_models": "Editar modelos",
|
||||
"compare_edit_variables": "Editar variables",
|
||||
"compare_edit_models_description": "Busca modelos o selecciona familias completas de proveedores.",
|
||||
"compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.",
|
||||
"compare_search_models": "Buscar modelos",
|
||||
"compare_search_variables": "Buscar variables",
|
||||
"compare_show_model_names": "Mostrar nombres de modelos",
|
||||
"compare_hide_model_names": "Ocultar nombres de modelos",
|
||||
"compare_only_selected": "Solo seleccionados",
|
||||
"compare_select_group": "Seleccionar todo",
|
||||
"compare_clear_group": "Deseleccionar todo",
|
||||
"compare_no_matching_options": "No hay opciones coincidentes.",
|
||||
"compare_no_models_selected": "No hay modelos seleccionados.",
|
||||
"compare_remove_model": "Quitar {model}",
|
||||
"compare_timeline_title": "Cronología de comparación de modelos",
|
||||
"compare_model_label": "Modelo",
|
||||
"page_compare_title": "Comparación de modelos meteorológicos",
|
||||
"page_compare_description": "Compara previsiones horarias de varios modelos meteorológicos para cualquier lugar.",
|
||||
"compare_direction_subtitle": "Dispersión de dirección entre {count} modelos · sin media",
|
||||
"compare_scalar_subtitle": "Entre {count} modelos · discontinuo = media de modelos",
|
||||
"compare_precipitation_subtitle": "Entre {count} modelos · franja inferior = concordancia de precipitación",
|
||||
"compare_precipitation_agreement": "Concordancia",
|
||||
"compare_precipitation_agreement_tooltip": "{wet}/{available} con precipitación · mediana {median} {unit} · rango {min}–{max} {unit}",
|
||||
"compare_model_mean": "Media de modelos",
|
||||
"compare_showing_previous": "La comparación anterior sigue visible.",
|
||||
"compare_empty_selection": "Selecciona al menos un modelo y una variable meteorológica.",
|
||||
"compare_no_data": "Ninguno de los modelos seleccionados ofrece datos útiles para este lugar y selección.",
|
||||
"compare_weather_codes_only": "Las condiciones y la nubosidad aparecen en la cronología inferior. Selecciona otra variable para añadir un gráfico.",
|
||||
"compare_model_colors": "Colores de los modelos",
|
||||
"compare_group_temperature": "Temperatura y humedad",
|
||||
"compare_group_precipitation": "Precipitación y condiciones",
|
||||
"compare_group_clouds": "Presión y nubes",
|
||||
"compare_group_wind": "Viento y atmósfera",
|
||||
"compare_group_upper_air": "Temperatura en altura",
|
||||
"compare_many_models_warning": "Hay {count} modelos seleccionados. La comparación puede ser más lenta y los colores más difíciles de distinguir.",
|
||||
"compare_timeline_hint": "Condiciones por modelo; el fondo de los pictogramas indica la nubosidad total",
|
||||
"compare_timeline_scroll_aria": "Cronología meteorológica desplazable por modelos",
|
||||
"compare_timeline_caption": "Condiciones meteorológicas horarias por modelo de previsión",
|
||||
"ensemble_trimmed": "El ensemble de este modelo solo llega unos {days} días, así que la dispersión se recorta a su rango disponible.",
|
||||
"seasonal_explainer_before": "Una previsión estacional muestra cuánto es probable que todo un mes",
|
||||
"seasonal_explainer_strong": "se desvíe de su normal climática",
|
||||
@@ -335,5 +388,35 @@
|
||||
"cadence_varies": "variable",
|
||||
"model_group_automatic": "Automático",
|
||||
"model_group_reanalysis": "Reanálisis del ECMWF",
|
||||
"model_group_regional_reanalysis": "Reanálisis regional"
|
||||
"model_group_regional_reanalysis": "Reanálisis regional",
|
||||
"wmo_0": "Cielo despejado",
|
||||
"wmo_1": "Mayormente despejado",
|
||||
"wmo_2": "Parcialmente nublado",
|
||||
"wmo_3": "Cubierto",
|
||||
"wmo_45": "Niebla",
|
||||
"wmo_48": "Niebla engelante",
|
||||
"wmo_51": "Llovizna débil",
|
||||
"wmo_53": "Llovizna moderada",
|
||||
"wmo_55": "Llovizna intensa",
|
||||
"wmo_56": "Llovizna engelante débil",
|
||||
"wmo_57": "Llovizna engelante intensa",
|
||||
"wmo_61": "Lluvia débil",
|
||||
"wmo_63": "Lluvia moderada",
|
||||
"wmo_65": "Lluvia intensa",
|
||||
"wmo_66": "Lluvia engelante débil",
|
||||
"wmo_67": "Lluvia engelante intensa",
|
||||
"wmo_71": "Nevada débil",
|
||||
"wmo_73": "Nevada moderada",
|
||||
"wmo_75": "Nevada intensa",
|
||||
"wmo_77": "Granos de nieve",
|
||||
"wmo_80": "Chubascos débiles",
|
||||
"wmo_81": "Chubascos moderados",
|
||||
"wmo_82": "Chubascos violentos",
|
||||
"wmo_85": "Chubascos de nieve débiles",
|
||||
"wmo_86": "Chubascos de nieve intensos",
|
||||
"wmo_95": "Tormenta",
|
||||
"wmo_96": "Tormenta con granizo ligero",
|
||||
"wmo_99": "Tormenta con granizo fuerte",
|
||||
"summary_read_more": "Leer más",
|
||||
"summary_read_less": "Mostrar menos"
|
||||
}
|
||||
|
||||
+91
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "Prévisions à 7 jours",
|
||||
"nav_week": "Prévisions hebdomadaires",
|
||||
"nav_compare": "Comparaison des modèles",
|
||||
"nav_14day": "Prévisions à 14 jours",
|
||||
"nav_seasonal": "Saisonnier",
|
||||
@@ -44,11 +44,11 @@
|
||||
"day_today": "Aujourd'hui",
|
||||
"day_tomorrow": "Demain",
|
||||
"day_yesterday": "Hier",
|
||||
"page_week_subtitle": "prévisions à 7 jours",
|
||||
"page_compare_subtitle": "comparaison des modèles",
|
||||
"page_14day_subtitle": "prévision d'ensemble à 14 jours",
|
||||
"page_seasonal_subtitle": "tendances saisonnières",
|
||||
"page_historical_subtitle": "météo historique",
|
||||
"page_week_subtitle": "Prévisions hebdomadaires",
|
||||
"page_compare_subtitle": "Comparaison des modèles",
|
||||
"page_14day_subtitle": "Prévision d'ensemble à 14 jours",
|
||||
"page_seasonal_subtitle": "Tendances saisonnières",
|
||||
"page_historical_subtitle": "Météo historique",
|
||||
"hourly_heading": "par heure",
|
||||
"hourly_variables": "Variables",
|
||||
"meteograms_heading": "Météogrammes",
|
||||
@@ -260,6 +260,8 @@
|
||||
"table_interval_aria": "Intervalle horaire",
|
||||
"table_now": "Maintenant",
|
||||
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
|
||||
"page_loading": "Chargement…",
|
||||
"page_loading_dismiss": "Fermer",
|
||||
"charts_loading": "Chargement des graphiques…",
|
||||
"chart_download": "Télécharger le météogramme en PNG",
|
||||
"chart_credit_viz": "visualisation par",
|
||||
@@ -267,7 +269,7 @@
|
||||
"legend_show": "Afficher la légende",
|
||||
"meteograms_none_before": "Aucun météogramme configuré,",
|
||||
"meteograms_none_action": "ajoutez des variables",
|
||||
"meteograms_none_historical": "Aucun météogramme configuré. Ajoutez des variables depuis la page 7 jours.",
|
||||
"meteograms_none_historical": "Aucun météogramme configuré. Ajoutez des variables depuis la page des prévisions hebdomadaires.",
|
||||
"search_searching": "Recherche…",
|
||||
"search_favorites": "Favoris",
|
||||
"search_recent": "Récents",
|
||||
@@ -280,9 +282,60 @@
|
||||
"model_automatic_selection": "Sélection automatique",
|
||||
"model_selector_aria": "Sélection : {label}",
|
||||
"compare_models_heading": "Modèles",
|
||||
"compare_models_choose": "Choisir les modèles à comparer",
|
||||
"compare_variables_heading": "Variables météo horaires",
|
||||
"compare_standard_preset": "Comparaison standard",
|
||||
"compare_weather_conditions": "Conditions & couverture nuageuse",
|
||||
"compare_wind_direction_height": "Direction du vent à {height} m",
|
||||
"compare_standard_preset_description": "Un point de départ équilibré pour comparer les modèles de prévision.",
|
||||
"compare_preset_active": "Active",
|
||||
"compare_custom_selection": "Personnalisée · {count} sélectionnées",
|
||||
"compare_customize_variables": "Personnaliser les variables",
|
||||
"compare_restore_defaults": "Rétablir la sélection standard",
|
||||
"compare_variables_pending": "Sélection des variables modifiée",
|
||||
"compare_selected_models": "Modèles sélectionnés",
|
||||
"compare_selection_pending": "Sélection modifiée",
|
||||
"compare_apply_selection": "Appliquer et recharger les graphiques",
|
||||
"compare_discard_changes": "Annuler les modifications",
|
||||
"compare_customize_models": "Personnaliser les modèles",
|
||||
"compare_edit_models": "Modifier les modèles",
|
||||
"compare_edit_variables": "Modifier les variables",
|
||||
"compare_edit_models_description": "Recherchez des modèles ou sélectionnez des fournisseurs entiers.",
|
||||
"compare_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques entiers.",
|
||||
"compare_search_models": "Rechercher des modèles",
|
||||
"compare_search_variables": "Rechercher des variables",
|
||||
"compare_show_model_names": "Afficher les noms des modèles",
|
||||
"compare_hide_model_names": "Masquer les noms des modèles",
|
||||
"compare_only_selected": "Sélection uniquement",
|
||||
"compare_select_group": "Tout sélectionner",
|
||||
"compare_clear_group": "Tout désélectionner",
|
||||
"compare_no_matching_options": "Aucune option correspondante.",
|
||||
"compare_no_models_selected": "Aucun modèle sélectionné.",
|
||||
"compare_remove_model": "Supprimer {model}",
|
||||
"compare_timeline_title": "Chronologie de comparaison des modèles",
|
||||
"compare_model_label": "Modèle",
|
||||
"page_compare_title": "Comparaison des modèles météo",
|
||||
"page_compare_description": "Comparez les prévisions horaires de plusieurs modèles météo pour n’importe quel lieu.",
|
||||
"compare_direction_subtitle": "Dispersion des directions sur {count} modèles · sans moyenne",
|
||||
"compare_scalar_subtitle": "Sur {count} modèles · pointillés = moyenne des modèles",
|
||||
"compare_precipitation_subtitle": "Sur {count} modèles · bande inférieure = accord sur les précipitations",
|
||||
"compare_precipitation_agreement": "Accord",
|
||||
"compare_precipitation_agreement_tooltip": "{wet}/{available} avec précipitations · médiane {median} {unit} · plage {min}–{max} {unit}",
|
||||
"compare_model_mean": "Moyenne des modèles",
|
||||
"compare_showing_previous": "La dernière comparaison réussie reste affichée.",
|
||||
"compare_empty_selection": "Sélectionnez au moins un modèle et une variable météo.",
|
||||
"compare_no_data": "Aucun des modèles sélectionnés ne fournit de données exploitables pour ce lieu et cette sélection.",
|
||||
"compare_weather_codes_only": "Les conditions et la couverture nuageuse sont affichées dans la chronologie ci-dessous. Sélectionnez une autre variable pour ajouter un graphique.",
|
||||
"compare_model_colors": "Couleurs des modèles",
|
||||
"compare_group_temperature": "Température et humidité",
|
||||
"compare_group_precipitation": "Précipitations et conditions",
|
||||
"compare_group_clouds": "Pression et nuages",
|
||||
"compare_group_wind": "Vent et atmosphère",
|
||||
"compare_group_upper_air": "Température en altitude",
|
||||
"compare_many_models_warning": "{count} modèles sont sélectionnés. La comparaison peut être plus lente et les couleurs plus difficiles à distinguer.",
|
||||
"compare_timeline_hint": "Conditions par modèle ; le fond des pictogrammes indique la couverture nuageuse totale",
|
||||
"compare_timeline_scroll_aria": "Chronologie météo des modèles à défilement horizontal",
|
||||
"compare_timeline_caption": "Conditions météo horaires par modèle de prévision",
|
||||
"ensemble_trimmed": "L'ensemble de ce modèle ne va qu'à environ {days} jours, la dispersion est donc limitée à sa portée disponible.",
|
||||
"seasonal_explainer_before": "Une prévision saisonnière montre dans quelle mesure un mois entier devrait",
|
||||
"seasonal_explainer_strong": "s'écarter de sa normale climatique",
|
||||
@@ -335,5 +388,35 @@
|
||||
"cadence_varies": "variable",
|
||||
"model_group_automatic": "Automatique",
|
||||
"model_group_reanalysis": "Réanalyse ECMWF",
|
||||
"model_group_regional_reanalysis": "Réanalyse régionale"
|
||||
"model_group_regional_reanalysis": "Réanalyse régionale",
|
||||
"wmo_0": "Ciel dégagé",
|
||||
"wmo_1": "Plutôt dégagé",
|
||||
"wmo_2": "Partiellement nuageux",
|
||||
"wmo_3": "Couvert",
|
||||
"wmo_45": "Brouillard",
|
||||
"wmo_48": "Brouillard givrant",
|
||||
"wmo_51": "Bruine faible",
|
||||
"wmo_53": "Bruine modérée",
|
||||
"wmo_55": "Bruine dense",
|
||||
"wmo_56": "Bruine verglaçante faible",
|
||||
"wmo_57": "Bruine verglaçante dense",
|
||||
"wmo_61": "Pluie faible",
|
||||
"wmo_63": "Pluie modérée",
|
||||
"wmo_65": "Pluie forte",
|
||||
"wmo_66": "Pluie verglaçante faible",
|
||||
"wmo_67": "Pluie verglaçante forte",
|
||||
"wmo_71": "Neige faible",
|
||||
"wmo_73": "Neige modérée",
|
||||
"wmo_75": "Neige forte",
|
||||
"wmo_77": "Grésil",
|
||||
"wmo_80": "Averses faibles",
|
||||
"wmo_81": "Averses modérées",
|
||||
"wmo_82": "Averses violentes",
|
||||
"wmo_85": "Averses de neige faibles",
|
||||
"wmo_86": "Averses de neige fortes",
|
||||
"wmo_95": "Orage",
|
||||
"wmo_96": "Orage avec grêle légère",
|
||||
"wmo_99": "Orage avec forte grêle",
|
||||
"summary_read_more": "Lire la suite",
|
||||
"summary_read_less": "Afficher moins"
|
||||
}
|
||||
|
||||
+91
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "Previsioni a 7 giorni",
|
||||
"nav_week": "Previsioni settimanali",
|
||||
"nav_compare": "Confronto modelli",
|
||||
"nav_14day": "Previsioni a 14 giorni",
|
||||
"nav_seasonal": "Stagionale",
|
||||
@@ -44,11 +44,11 @@
|
||||
"day_today": "Oggi",
|
||||
"day_tomorrow": "Domani",
|
||||
"day_yesterday": "Ieri",
|
||||
"page_week_subtitle": "previsioni a 7 giorni",
|
||||
"page_compare_subtitle": "confronto modelli",
|
||||
"page_14day_subtitle": "previsione d'insieme a 14 giorni",
|
||||
"page_seasonal_subtitle": "tendenze stagionali",
|
||||
"page_historical_subtitle": "meteo storico",
|
||||
"page_week_subtitle": "Previsioni settimanali",
|
||||
"page_compare_subtitle": "Confronto modelli",
|
||||
"page_14day_subtitle": "Previsione d'insieme a 14 giorni",
|
||||
"page_seasonal_subtitle": "Tendenze stagionali",
|
||||
"page_historical_subtitle": "Meteo storico",
|
||||
"hourly_heading": "orario",
|
||||
"hourly_variables": "Variabili",
|
||||
"meteograms_heading": "Meteogrammi",
|
||||
@@ -260,6 +260,8 @@
|
||||
"table_interval_aria": "Intervallo orario",
|
||||
"table_now": "Ora",
|
||||
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
|
||||
"page_loading": "Caricamento…",
|
||||
"page_loading_dismiss": "Chiudi",
|
||||
"charts_loading": "Caricamento dei grafici…",
|
||||
"chart_download": "Scarica il meteogramma come immagine PNG",
|
||||
"chart_credit_viz": "visualizzazione di",
|
||||
@@ -267,7 +269,7 @@
|
||||
"legend_show": "Mostra legenda",
|
||||
"meteograms_none_before": "Nessun meteogramma configurato,",
|
||||
"meteograms_none_action": "aggiungi qualche variabile",
|
||||
"meteograms_none_historical": "Nessun meteogramma configurato. Aggiungi variabili dalla pagina a 7 giorni.",
|
||||
"meteograms_none_historical": "Nessun meteogramma configurato. Aggiungi variabili dalla pagina delle previsioni settimanali.",
|
||||
"search_searching": "Ricerca in corso…",
|
||||
"search_favorites": "Preferiti",
|
||||
"search_recent": "Recenti",
|
||||
@@ -280,9 +282,60 @@
|
||||
"model_automatic_selection": "Selezione automatica",
|
||||
"model_selector_aria": "Selezione di {label}",
|
||||
"compare_models_heading": "Modelli",
|
||||
"compare_models_choose": "Scegli i modelli da confrontare",
|
||||
"compare_variables_heading": "Variabili meteo orarie",
|
||||
"compare_standard_preset": "Confronto standard",
|
||||
"compare_weather_conditions": "Condizioni e copertura nuvolosa",
|
||||
"compare_wind_direction_height": "Direzione del vento a {height} m",
|
||||
"compare_standard_preset_description": "Un punto di partenza equilibrato per confrontare i modelli previsionali.",
|
||||
"compare_preset_active": "Attivo",
|
||||
"compare_custom_selection": "Personalizzata · {count} selezionate",
|
||||
"compare_customize_variables": "Personalizza le variabili",
|
||||
"compare_restore_defaults": "Ripristina la selezione standard",
|
||||
"compare_variables_pending": "Selezione delle variabili modificata",
|
||||
"compare_selected_models": "Modelli selezionati",
|
||||
"compare_selection_pending": "Selezione modificata",
|
||||
"compare_apply_selection": "Applica e ricarica i grafici",
|
||||
"compare_discard_changes": "Annulla modifiche",
|
||||
"compare_customize_models": "Personalizza modelli",
|
||||
"compare_edit_models": "Modifica modelli",
|
||||
"compare_edit_variables": "Modifica variabili",
|
||||
"compare_edit_models_description": "Cerca modelli o seleziona intere famiglie di fornitori.",
|
||||
"compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.",
|
||||
"compare_search_models": "Cerca modelli",
|
||||
"compare_search_variables": "Cerca variabili",
|
||||
"compare_show_model_names": "Mostra nomi dei modelli",
|
||||
"compare_hide_model_names": "Nascondi nomi dei modelli",
|
||||
"compare_only_selected": "Solo selezionati",
|
||||
"compare_select_group": "Seleziona tutto",
|
||||
"compare_clear_group": "Deseleziona tutto",
|
||||
"compare_no_matching_options": "Nessuna opzione corrispondente.",
|
||||
"compare_no_models_selected": "Nessun modello selezionato.",
|
||||
"compare_remove_model": "Rimuovi {model}",
|
||||
"compare_timeline_title": "Cronologia del confronto tra modelli",
|
||||
"compare_model_label": "Modello",
|
||||
"page_compare_title": "Confronto dei modelli meteo",
|
||||
"page_compare_description": "Confronta le previsioni orarie di più modelli meteo per qualsiasi località.",
|
||||
"compare_direction_subtitle": "Dispersione della direzione tra {count} modelli · senza media",
|
||||
"compare_scalar_subtitle": "Tra {count} modelli · tratteggiato = media dei modelli",
|
||||
"compare_precipitation_subtitle": "Tra {count} modelli · fascia inferiore = concordanza sulle precipitazioni",
|
||||
"compare_precipitation_agreement": "Concordanza",
|
||||
"compare_precipitation_agreement_tooltip": "{wet}/{available} con precipitazioni · mediana {median} {unit} · intervallo {min}–{max} {unit}",
|
||||
"compare_model_mean": "Media dei modelli",
|
||||
"compare_showing_previous": "Il confronto precedente rimane visibile.",
|
||||
"compare_empty_selection": "Seleziona almeno un modello e una variabile meteo.",
|
||||
"compare_no_data": "Nessuno dei modelli selezionati fornisce dati utilizzabili per questa località e selezione.",
|
||||
"compare_weather_codes_only": "Le condizioni e la copertura nuvolosa sono mostrate nella cronologia in basso. Seleziona un’altra variabile per aggiungere un grafico.",
|
||||
"compare_model_colors": "Colori dei modelli",
|
||||
"compare_group_temperature": "Temperatura e umidità",
|
||||
"compare_group_precipitation": "Precipitazioni e condizioni",
|
||||
"compare_group_clouds": "Pressione e nuvole",
|
||||
"compare_group_wind": "Vento e atmosfera",
|
||||
"compare_group_upper_air": "Temperatura in quota",
|
||||
"compare_many_models_warning": "Sono selezionati {count} modelli. Il confronto può essere più lento e i colori più difficili da distinguere.",
|
||||
"compare_timeline_hint": "Condizioni per modello; lo sfondo dei pittogrammi indica la copertura nuvolosa totale",
|
||||
"compare_timeline_scroll_aria": "Cronologia meteo scorrevole dei modelli",
|
||||
"compare_timeline_caption": "Condizioni meteo orarie per modello di previsione",
|
||||
"ensemble_trimmed": "L'ensemble di questo modello arriva solo a circa {days} giorni, quindi la dispersione è ridotta al periodo disponibile.",
|
||||
"seasonal_explainer_before": "Una previsione stagionale mostra quanto un intero mese potrebbe",
|
||||
"seasonal_explainer_strong": "discostarsi dalla sua normale climatica",
|
||||
@@ -335,5 +388,35 @@
|
||||
"cadence_varies": "variabile",
|
||||
"model_group_automatic": "Automatico",
|
||||
"model_group_reanalysis": "Rianalisi ECMWF",
|
||||
"model_group_regional_reanalysis": "Rianalisi regionale"
|
||||
"model_group_regional_reanalysis": "Rianalisi regionale",
|
||||
"wmo_0": "Cielo sereno",
|
||||
"wmo_1": "Prevalentemente sereno",
|
||||
"wmo_2": "Parzialmente nuvoloso",
|
||||
"wmo_3": "Coperto",
|
||||
"wmo_45": "Nebbia",
|
||||
"wmo_48": "Nebbia gelata",
|
||||
"wmo_51": "Pioviggine debole",
|
||||
"wmo_53": "Pioviggine moderata",
|
||||
"wmo_55": "Pioviggine intensa",
|
||||
"wmo_56": "Pioviggine congelantesi debole",
|
||||
"wmo_57": "Pioviggine congelantesi intensa",
|
||||
"wmo_61": "Pioggia debole",
|
||||
"wmo_63": "Pioggia moderata",
|
||||
"wmo_65": "Pioggia forte",
|
||||
"wmo_66": "Pioggia congelantesi debole",
|
||||
"wmo_67": "Pioggia congelantesi forte",
|
||||
"wmo_71": "Nevicata debole",
|
||||
"wmo_73": "Nevicata moderata",
|
||||
"wmo_75": "Nevicata forte",
|
||||
"wmo_77": "Granuli di neve",
|
||||
"wmo_80": "Rovesci deboli",
|
||||
"wmo_81": "Rovesci moderati",
|
||||
"wmo_82": "Rovesci violenti",
|
||||
"wmo_85": "Rovesci di neve deboli",
|
||||
"wmo_86": "Rovesci di neve forti",
|
||||
"wmo_95": "Temporale",
|
||||
"wmo_96": "Temporale con grandine leggera",
|
||||
"wmo_99": "Temporale con grandine forte",
|
||||
"summary_read_more": "Leggi tutto",
|
||||
"summary_read_less": "Mostra meno"
|
||||
}
|
||||
|
||||
Generated
-42
@@ -792,9 +792,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -812,9 +809,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -832,9 +826,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -852,9 +843,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -872,9 +860,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -892,9 +877,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1240,9 +1222,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1260,9 +1239,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1280,9 +1256,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1300,9 +1273,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3124,9 +3094,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3148,9 +3115,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3172,9 +3136,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3196,9 +3157,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- Declared in the shell, not in the layout head: the SPA fallback page
|
||||
(404.html) ships no rendered head, so without this the browser falls
|
||||
back to requesting /favicon.ico and takes a 404 on every load of an
|
||||
unprerendered URL. -->
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<script>
|
||||
// apply the persisted theme before first paint to avoid a flash
|
||||
try {
|
||||
|
||||
@@ -24,13 +24,15 @@
|
||||
/** Series display name (used in legend and tooltip) */
|
||||
name: string;
|
||||
/** Render style */
|
||||
type: 'line' | 'bar';
|
||||
type: 'line' | 'bar' | 'point';
|
||||
/** Any CSS color string */
|
||||
color: string;
|
||||
/** One value per timestamp; null values break lines */
|
||||
data: (number | null)[];
|
||||
/** Line width in px (default 2); 0 draws only the area fill */
|
||||
width?: number;
|
||||
/** Radius in px for point series (default 3). */
|
||||
pointRadius?: number;
|
||||
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
|
||||
fill?: boolean;
|
||||
/** Area fill coloured by the value scale (segmentColor), fading to
|
||||
@@ -74,6 +76,20 @@
|
||||
cloudLayer?: 'high' | 'mid' | 'low';
|
||||
}
|
||||
|
||||
export interface ChartAgreementPoint {
|
||||
wetCount: number;
|
||||
availableCount: number;
|
||||
median: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface ChartAgreementStrip {
|
||||
points: (ChartAgreementPoint | null)[];
|
||||
label: string;
|
||||
format: (point: ChartAgreementPoint) => string;
|
||||
}
|
||||
|
||||
interface GroupState {
|
||||
range: { start: number; end: number } | null;
|
||||
hover: number | null;
|
||||
@@ -101,6 +117,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a non-chart surface that participates in synchronized interactions. */
|
||||
export function registerGroupMember(name: string): () => void {
|
||||
acquireGroup(name);
|
||||
let registered = true;
|
||||
return () => {
|
||||
if (!registered) return;
|
||||
registered = false;
|
||||
releaseGroup(name);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the shared crosshair of a chart group from the outside (e.g. hovering
|
||||
* the hourly table). `time` is epoch seconds, or null to clear. No-op if no
|
||||
@@ -108,7 +135,16 @@
|
||||
*/
|
||||
export function setGroupHover(name: string, time: number | null): void {
|
||||
const state = groups[name];
|
||||
if (state) state.hover = time;
|
||||
if (state && state.hover !== time) state.hover = time;
|
||||
}
|
||||
|
||||
/** Set the synchronized visible time range for a registered chart group. */
|
||||
export function setGroupRange(
|
||||
name: string,
|
||||
range: { start: number; end: number } | null
|
||||
): void {
|
||||
const state = groups[name];
|
||||
if (state) state.range = range;
|
||||
}
|
||||
|
||||
/** Current shared zoom range of a group (null = full range), reactive. */
|
||||
@@ -151,6 +187,8 @@
|
||||
windArrows?: { t: number; deg: number }[];
|
||||
/** Highlighted time range (epoch seconds), e.g. the selected day */
|
||||
highlight?: { start: number; end: number };
|
||||
/** Compact per-timestamp model-agreement band below the plot. */
|
||||
agreementStrip?: ChartAgreementStrip;
|
||||
/** Unit label for the left y axis (also used in tooltip values) */
|
||||
unit?: string;
|
||||
/** Unit label for the right y axis; when set, right-axis labels are drawn */
|
||||
@@ -167,6 +205,12 @@
|
||||
yPadBottom?: number;
|
||||
/** Fixed left-axis maximum */
|
||||
yMax?: number;
|
||||
/** Explicit left-axis tick values (for categorical/fixed domains). */
|
||||
yTicks?: number[];
|
||||
/** Custom left-axis tick formatter. */
|
||||
yTickFormat?: (value: number) => string;
|
||||
/** Use abbreviated y-axis labels and omit verbose gutter labels. */
|
||||
compactYAxis?: boolean;
|
||||
/** Force the derived left axis to include zero (default true) */
|
||||
zeroBaseLeft?: boolean;
|
||||
/** Fixed right-axis minimum (default 0) */
|
||||
@@ -181,6 +225,10 @@
|
||||
/** Reserve this many top icon-rows even if this chart has fewer, so a row
|
||||
* of stacked charts share the same plot rectangle */
|
||||
reserveTopRows?: number;
|
||||
/** Override the plot's left inset so heterogeneous synchronized panels align. */
|
||||
plotInsetLeft?: number;
|
||||
/** Override the plot's right inset so heterogeneous synchronized panels align. */
|
||||
plotInsetRight?: number;
|
||||
/** Chart title drawn top-left on the canvas */
|
||||
title?: string;
|
||||
/** Smaller subtitle drawn under the title */
|
||||
@@ -191,6 +239,8 @@
|
||||
showNow?: boolean;
|
||||
/** Draw the Open-Meteo.com credit bottom-right */
|
||||
showCredit?: boolean;
|
||||
/** Accessible description for the interactive canvas. */
|
||||
ariaLabel?: string;
|
||||
/** Optional CSS class for the outer container */
|
||||
class?: string;
|
||||
}
|
||||
@@ -203,12 +253,16 @@
|
||||
pictograms = [],
|
||||
windArrows = [],
|
||||
highlight,
|
||||
agreementStrip,
|
||||
unit = '',
|
||||
unitRight,
|
||||
height = 300,
|
||||
group,
|
||||
yMin,
|
||||
yMax,
|
||||
yTicks,
|
||||
yTickFormat,
|
||||
compactYAxis = false,
|
||||
yPadTop,
|
||||
yPadBottom,
|
||||
zeroBaseLeft = true,
|
||||
@@ -217,11 +271,14 @@
|
||||
invertRight = false,
|
||||
reserveRightAxis = false,
|
||||
reserveTopRows = 0,
|
||||
plotInsetLeft,
|
||||
plotInsetRight,
|
||||
title,
|
||||
subtitle,
|
||||
showLegend = false,
|
||||
showNow = true,
|
||||
showCredit = false,
|
||||
ariaLabel,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
@@ -232,6 +289,8 @@
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const PAD_BOTTOM = 34;
|
||||
const AGREEMENT_STRIP_HEIGHT = 10;
|
||||
const AGREEMENT_STRIP_GAP = 3;
|
||||
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
|
||||
const HOUR = 3600;
|
||||
// Top icon rows (weather pictograms / wind arrows)
|
||||
@@ -274,6 +333,20 @@
|
||||
return color;
|
||||
}
|
||||
|
||||
function agreementMarkerColor(point: ChartAgreementPoint): string {
|
||||
if (point.wetCount === 0) return '#6b7280';
|
||||
const wetShare = point.wetCount / Math.max(1, point.availableCount);
|
||||
return wetShare <= 0.5 ? '#d97706' : '#2563eb';
|
||||
}
|
||||
|
||||
function agreementFillColor(point: ChartAgreementPoint, mutedColor: string): string {
|
||||
if (point.wetCount === 0) return withAlpha(mutedColor, 0.3);
|
||||
const wetShare = point.wetCount / Math.max(1, point.availableCount);
|
||||
const agreement = Math.abs(wetShare - 0.5) * 2;
|
||||
if (wetShare <= 0.5) return withAlpha('#d97706', 0.35 + (1 - agreement) * 0.35);
|
||||
return withAlpha('#2563eb', 0.3 + agreement * 0.6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Light [1 2 1] smoothing pass. Cloud cover is noisy hour to hour; smoothing
|
||||
* the values before interpolating gives the band a slow, rolling silhouette
|
||||
@@ -368,6 +441,10 @@
|
||||
const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null;
|
||||
let localRange = $state<{ start: number; end: number } | null>(null);
|
||||
let localHover = $state<number | null>(null);
|
||||
// Pointer hover is visual only. Live-region announcements are enabled by
|
||||
// keyboard navigation so mouse movement does not continuously invalidate the
|
||||
// accessibility tree (and only the keyboard-focused chart announces changes).
|
||||
let announceHover = $state(false);
|
||||
|
||||
onDestroy(() => {
|
||||
if (groupName) releaseGroup(groupName);
|
||||
@@ -407,12 +484,14 @@
|
||||
return () => mq.removeEventListener('change', apply);
|
||||
});
|
||||
let isNarrow = $derived(belowDesktop || (width > 0 && width < 520));
|
||||
let padLeft = $derived(isNarrow ? 26 : 60);
|
||||
let padLeft = $derived(plotInsetLeft ?? (isNarrow ? (yTicks?.length ? 52 : 26) : 60));
|
||||
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
|
||||
// has a right axis, so a stacked row of charts share the same plot width. On
|
||||
// narrow (mobile) screens we don't reserve it — the graph uses the full width
|
||||
// and the right-axis labels are drawn overlaid on top instead (see below).
|
||||
let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
|
||||
let padRight = $derived(
|
||||
plotInsetRight ?? (isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20)
|
||||
);
|
||||
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
||||
// a stacked row of charts the same height even if some have fewer icon rows —
|
||||
// but only on wide screens: on mobile that uniform band wastes precious
|
||||
@@ -421,8 +500,11 @@
|
||||
let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows));
|
||||
// tighter top/bottom gutters on mobile so charts don't waste vertical space
|
||||
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
|
||||
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
|
||||
let padBottom = $derived(isNarrow ? 24 : PAD_BOTTOM);
|
||||
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 22 : 28) + iconRows * iconRowH);
|
||||
let agreementStripOffset = $derived(
|
||||
agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0
|
||||
);
|
||||
let padBottom = $derived((isNarrow ? 24 : PAD_BOTTOM) + agreementStripOffset);
|
||||
let plotW = $derived(Math.max(1, width - padLeft - padRight));
|
||||
let plotH = $derived(Math.max(1, height - padTop - padBottom));
|
||||
|
||||
@@ -536,8 +618,11 @@
|
||||
}
|
||||
|
||||
function setHover(time: number | null): void {
|
||||
if (groupState) groupState.hover = time;
|
||||
else localHover = time;
|
||||
if (groupState) {
|
||||
if (groupState.hover !== time) groupState.hover = time;
|
||||
} else if (localHover !== time) {
|
||||
localHover = time;
|
||||
}
|
||||
}
|
||||
|
||||
function applyRange(start: number, end: number): void {
|
||||
@@ -569,7 +654,7 @@
|
||||
// The canvas alone omits the DOM overlays (weather/wind icons), the panel
|
||||
// title (rendered by the parent), and the legend (an HTML row). This builds a
|
||||
// standalone canvas that includes all of them so downloads look like the page.
|
||||
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||
const iconImageCache = new SvelteMap<string, Promise<HTMLImageElement>>();
|
||||
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
|
||||
const key = `${name}|${color}`;
|
||||
let p = iconImageCache.get(key);
|
||||
@@ -762,12 +847,23 @@
|
||||
: `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`;
|
||||
rows.push({ name: s.shortName ?? s.name, color: s.color, value });
|
||||
}
|
||||
const agreementPoint = agreementStrip?.points[hoverIdx];
|
||||
if (agreementStrip && agreementPoint && agreementPoint.availableCount > 0) {
|
||||
rows.push({
|
||||
name: agreementStrip.label,
|
||||
color: agreementMarkerColor(agreementPoint),
|
||||
value: agreementStrip.format(agreementPoint)
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0);
|
||||
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
|
||||
let tooltipFlip = $derived(tooltipX > width * 0.55);
|
||||
let crosshairVisible = $derived(
|
||||
hoverIdx >= 0 && timestamps[hoverIdx] >= viewStart && timestamps[hoverIdx] <= viewEnd && width > 0
|
||||
);
|
||||
|
||||
// ─── Pictograms (DOM overlay across the top) ─────────────────────────────────
|
||||
|
||||
@@ -979,11 +1075,13 @@
|
||||
ctx.font = font;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (
|
||||
let v = firstTick(leftScale);
|
||||
v <= leftScale.max + leftScale.step / 2;
|
||||
v += leftScale.step
|
||||
) {
|
||||
const leftTicks =
|
||||
yTicks ??
|
||||
Array.from(
|
||||
{ length: Math.floor((leftScale.max - firstTick(leftScale)) / leftScale.step) + 1 },
|
||||
(_, i) => firstTick(leftScale) + i * leftScale.step
|
||||
);
|
||||
for (const v of leftTicks) {
|
||||
const y = yPix(v, 'left');
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
@@ -992,7 +1090,13 @@
|
||||
ctx.lineTo(plotRight, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
|
||||
ctx.fillText(
|
||||
compactYAxis
|
||||
? v.toFixed(tickDecimals(leftScale.step))
|
||||
: (yTickFormat?.(v) ?? v.toFixed(tickDecimals(leftScale.step))),
|
||||
padLeft - 8,
|
||||
y
|
||||
);
|
||||
}
|
||||
|
||||
// Right axis labels in the gutter (desktop only). On mobile there is no
|
||||
@@ -1033,7 +1137,7 @@
|
||||
ctx.fillStyle = textColor;
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(tick.label, x, plotBottom + 7);
|
||||
ctx.fillText(tick.label, x, plotBottom + agreementStripOffset + 7);
|
||||
}
|
||||
|
||||
// Series (clipped to the plot area)
|
||||
@@ -1170,6 +1274,24 @@
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.type === 'point') {
|
||||
const radius = s.pointRadius ?? 3;
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.strokeStyle = outlineColor;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
const v = s.data[i];
|
||||
if (v === null || v === undefined || !isFinite(v)) continue;
|
||||
const t = timestamps[i];
|
||||
if (t < viewStart || t > viewEnd) continue;
|
||||
ctx.beginPath();
|
||||
ctx.arc(xPix(t), yPix(v, axis), radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Line series: draw fill and stroke per contiguous non-null run
|
||||
// (points outside the view are handled by the clip rect). Each point
|
||||
// is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set.
|
||||
@@ -1406,24 +1528,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Crosshair (snapped to the nearest timestamp)
|
||||
if (hoverIdx >= 0) {
|
||||
const t = timestamps[hoverIdx];
|
||||
if (t >= viewStart && t <= viewEnd) {
|
||||
const x = xPix(t);
|
||||
ctx.strokeStyle = textColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padTop);
|
||||
ctx.lineTo(x, plotBottom);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
if (agreementStrip) {
|
||||
const stripTop = plotBottom + AGREEMENT_STRIP_GAP;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(padLeft, stripTop, plotW, AGREEMENT_STRIP_HEIGHT);
|
||||
ctx.clip();
|
||||
for (let index = 0; index < timestamps.length; index++) {
|
||||
const point = agreementStrip.points[index];
|
||||
if (!point || point.availableCount === 0) continue;
|
||||
const time = timestamps[index];
|
||||
if (time < viewStart - interval || time > viewEnd + interval) continue;
|
||||
const left = Math.max(padLeft, xPix(time - interval / 2));
|
||||
const right = Math.min(plotRight, xPix(time + interval / 2));
|
||||
if (right <= left) continue;
|
||||
ctx.fillStyle = agreementFillColor(point, textColor);
|
||||
ctx.fillRect(left, stripTop, Math.max(1, right - left + 0.5), AGREEMENT_STRIP_HEIGHT);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.strokeStyle = withAlpha(gridColor, 0.8);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1);
|
||||
if (!compactYAxis) {
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '600 9px system-ui, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
agreementStrip.label,
|
||||
padLeft - 6,
|
||||
stripTop + AGREEMENT_STRIP_HEIGHT / 2,
|
||||
Math.max(24, padLeft - 10)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Axis unit labels
|
||||
ctx.font = font;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
@@ -1515,13 +1657,47 @@
|
||||
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
|
||||
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
|
||||
let selStartX = 0; // drag-to-zoom anchor (canvas-local px)
|
||||
let hoverFrame = 0;
|
||||
let queuedClientX = 0;
|
||||
let queuedHoverAction: 'none' | 'position' | 'clear' = 'none';
|
||||
|
||||
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
|
||||
const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x));
|
||||
|
||||
const updateHover = (e: PointerEvent): void => {
|
||||
const t = pixToTime(localX(e));
|
||||
setHover(t >= viewStart && t <= viewEnd ? t : null);
|
||||
const scheduleHoverFrame = (): void => {
|
||||
if (hoverFrame !== 0) return;
|
||||
hoverFrame = requestAnimationFrame(() => {
|
||||
hoverFrame = 0;
|
||||
const action = queuedHoverAction;
|
||||
queuedHoverAction = 'none';
|
||||
if (action === 'clear') {
|
||||
setHover(null);
|
||||
return;
|
||||
}
|
||||
if (action !== 'position') return;
|
||||
|
||||
// Geometry reads and timestamp lookup happen once per animation frame,
|
||||
// even when a high-polling pointer emits many events between frames.
|
||||
const t = pixToTime(queuedClientX - el.getBoundingClientRect().left);
|
||||
const time =
|
||||
t >= viewStart && t <= viewEnd && timestamps.length > 0
|
||||
? timestamps[nearestIndex(timestamps, t)]
|
||||
: null;
|
||||
setHover(time);
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleHoverPosition = (clientX: number): void => {
|
||||
if (announceHover) announceHover = false;
|
||||
queuedClientX = clientX;
|
||||
queuedHoverAction = 'position';
|
||||
scheduleHoverFrame();
|
||||
};
|
||||
|
||||
const scheduleHoverClear = (): void => {
|
||||
if (announceHover) announceHover = false;
|
||||
queuedHoverAction = 'clear';
|
||||
scheduleHoverFrame();
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent): void => {
|
||||
@@ -1533,7 +1709,7 @@
|
||||
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
|
||||
panStart = null;
|
||||
gesture = 'pinch';
|
||||
setHover(null);
|
||||
scheduleHoverClear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1573,7 +1749,7 @@
|
||||
const x = clampPlotX(localX(e));
|
||||
if (dragSelect || Math.abs(x - selStartX) >= 3) {
|
||||
dragSelect = { x0: selStartX, x1: x };
|
||||
setHover(null);
|
||||
scheduleHoverClear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1603,7 +1779,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointers.size <= 1) updateHover(e);
|
||||
if (pointers.size <= 1) scheduleHoverPosition(e.clientX);
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent): void => {
|
||||
@@ -1622,11 +1798,11 @@
|
||||
touchStart = null;
|
||||
gesture = 'none';
|
||||
}
|
||||
if (e.pointerType !== 'mouse') setHover(null);
|
||||
if (e.pointerType !== 'mouse') scheduleHoverClear();
|
||||
};
|
||||
|
||||
const onPointerLeave = (): void => {
|
||||
if (pointers.size === 0) setHover(null);
|
||||
if (pointers.size === 0) scheduleHoverClear();
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
@@ -1660,6 +1836,7 @@
|
||||
el.removeEventListener('pointerleave', onPointerLeave);
|
||||
el.removeEventListener('wheel', onWheel);
|
||||
el.removeEventListener('dblclick', onDblClick);
|
||||
if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1667,6 +1844,29 @@
|
||||
if (legendHidden.has(name)) legendHidden.delete(name);
|
||||
else legendHidden.add(name);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (timestamps.length === 0) return;
|
||||
const current = hoverTime ?? viewStart;
|
||||
const index = nearestIndex(timestamps, current);
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
const delta = event.key === 'ArrowLeft' ? -1 : 1;
|
||||
const next = Math.max(0, Math.min(timestamps.length - 1, index + delta));
|
||||
announceHover = true;
|
||||
setHover(timestamps[next]);
|
||||
event.preventDefault();
|
||||
} else if (event.key === '+' || event.key === '=') {
|
||||
zoomAt(current, 1 / 1.3);
|
||||
event.preventDefault();
|
||||
} else if (event.key === '-' || event.key === '_') {
|
||||
zoomAt(current, 1.3);
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'Escape') {
|
||||
announceHover = false;
|
||||
setViewRange(null);
|
||||
setHover(null);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={containerEl} class="relative w-full select-none {className}">
|
||||
@@ -1676,7 +1876,16 @@
|
||||
class="block w-full"
|
||||
style:height="{height}px"
|
||||
style:touch-action="pan-y"
|
||||
tabindex="0"
|
||||
aria-label={ariaLabel}
|
||||
onkeydown={handleKeydown}
|
||||
></canvas>
|
||||
<p class="sr-only" aria-live="polite">
|
||||
{#if announceHover && tooltipVisible}
|
||||
{formatZoned(new Date(timestamps[hoverIdx] * 1000), timezone, 'EEE d MMM HH:mm')}:
|
||||
{tooltipRows.map((row) => `${row.name} ${row.value}`).join(', ')}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- Drag-to-zoom selection rectangle -->
|
||||
{#if dragSelect}
|
||||
@@ -1685,7 +1894,7 @@
|
||||
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
|
||||
style:top="{padTop}px"
|
||||
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
|
||||
style:height="{plotH}px"
|
||||
style:height="{plotH + agreementStripOffset}px"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
@@ -1738,6 +1947,17 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hover is intentionally a DOM overlay: moving the crosshair must not
|
||||
repaint the expensive base canvas or every synchronized chart. -->
|
||||
{#if crosshairVisible}
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 z-[15] border-l border-dashed border-muted-foreground/70 will-change-transform"
|
||||
style:transform="translateX({tooltipX}px)"
|
||||
style:top="{padTop}px"
|
||||
style:height="{plotH + agreementStripOffset}px"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if tooltipVisible}
|
||||
<div
|
||||
class="pointer-events-none absolute z-20 rounded-md border border-border bg-popover px-3 py-2 text-xs whitespace-nowrap text-popover-foreground shadow-md"
|
||||
|
||||
+9
-10
@@ -33,19 +33,19 @@ export const CHART_COLORS = {
|
||||
// ─── Utility: Detect column-type variables ───────────────────────────────────
|
||||
|
||||
/** Units that should be rendered as bar/column charts instead of lines. */
|
||||
const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
|
||||
const COLUMN_UNITS = new Set(['mm', 'cm', 'in', 'inch', 'MJ/m²']);
|
||||
|
||||
/**
|
||||
* Returns true if the given unit should be rendered as a bar chart.
|
||||
*/
|
||||
export function isColumnUnit(unit: string): boolean {
|
||||
return COLUMN_UNITS.has(unit);
|
||||
return COLUMN_UNITS.has(unit.trim());
|
||||
}
|
||||
|
||||
// ─── Data Processing Helpers ─────────────────────────────────────────────────
|
||||
|
||||
export interface AverageResult {
|
||||
average: number[];
|
||||
average: (number | null)[];
|
||||
averageCount: number[];
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function calculateAverage(
|
||||
variable: string,
|
||||
timeLength: number
|
||||
): AverageResult {
|
||||
const average = new Array<number>(timeLength).fill(0);
|
||||
const totals = new Array<number>(timeLength).fill(0);
|
||||
const averageCount = new Array<number>(timeLength).fill(0);
|
||||
|
||||
for (const [model, values] of Object.entries(hourlyData)) {
|
||||
@@ -71,18 +71,17 @@ export function calculateAverage(
|
||||
|
||||
for (const [index, val] of (values as number[]).entries()) {
|
||||
if (val !== null && val !== undefined && isFinite(val)) {
|
||||
average[index] += val;
|
||||
if (index >= timeLength) continue;
|
||||
totals[index] += val;
|
||||
averageCount[index]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize average values
|
||||
for (let i = 0; i < timeLength; i++) {
|
||||
if (averageCount[i] > 0) {
|
||||
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
|
||||
}
|
||||
}
|
||||
const average = totals.map((total, i) =>
|
||||
averageCount[i] > 0 ? Math.round((total / averageCount[i]) * 10) / 10 : null
|
||||
);
|
||||
|
||||
return { average, averageCount };
|
||||
}
|
||||
|
||||
@@ -8,10 +8,16 @@
|
||||
export {
|
||||
default as CanvasChart,
|
||||
setGroupHover,
|
||||
setGroupRange,
|
||||
registerGroupMember,
|
||||
groupRange,
|
||||
groupHover
|
||||
} from './CanvasChart.svelte';
|
||||
export type { ChartSeries } from './CanvasChart.svelte';
|
||||
export type {
|
||||
ChartAgreementPoint,
|
||||
ChartAgreementStrip,
|
||||
ChartSeries
|
||||
} from './CanvasChart.svelte';
|
||||
|
||||
export { buildDaylightBands } from './bands';
|
||||
export type { DaylightBand } from './bands';
|
||||
|
||||
@@ -18,14 +18,15 @@
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
-->
|
||||
<script module lang="ts">
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
export type { ExportableChart } from './downloadChartsPng';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
|
||||
import {
|
||||
type ChartDownloadOptions,
|
||||
type ChartExportItem,
|
||||
type ExportableChart,
|
||||
downloadChartsPng
|
||||
} from './downloadChartsPng';
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
@@ -33,9 +34,11 @@
|
||||
|
||||
interface Props {
|
||||
/** Chart components available for download (undefined entries are skipped) */
|
||||
charts?: Array<ExportableChart | undefined | null>;
|
||||
charts?: Array<ChartExportItem | ExportableChart | undefined | null>;
|
||||
/** Base file name for downloaded images (without extension) */
|
||||
fileName?: string;
|
||||
/** Optional title and shared legend drawn into the combined PNG. */
|
||||
exportOptions?: ChartDownloadOptions;
|
||||
/** Optional CSS class for the outer container */
|
||||
class?: string;
|
||||
/** Slot for additional controls (switches, checkboxes, etc.) */
|
||||
@@ -45,6 +48,7 @@
|
||||
let {
|
||||
charts = [],
|
||||
fileName = 'drizzli-chart',
|
||||
exportOptions,
|
||||
class: className = '',
|
||||
controls
|
||||
}: Props = $props();
|
||||
@@ -55,7 +59,9 @@
|
||||
|
||||
// ─── Computed ───────────────────────────────────────────────────────────────
|
||||
|
||||
let hasCharts = $derived(charts.some((chart) => chart != null));
|
||||
let hasCharts = $derived(
|
||||
charts.some((item) => item != null && ('chart' in item ? item.chart != null : true))
|
||||
);
|
||||
|
||||
// ─── Download ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,7 +69,7 @@
|
||||
if (!hasCharts || downloading) return;
|
||||
downloading = true;
|
||||
try {
|
||||
await downloadChartsPng(charts, fileName);
|
||||
await downloadChartsPng(charts, fileName, exportOptions);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
downloading = false;
|
||||
|
||||
@@ -17,6 +17,17 @@ export interface ChartExportItem {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ExportLegendItem {
|
||||
name: string;
|
||||
color: string;
|
||||
style?: 'point' | 'line' | 'dashed' | 'bar';
|
||||
}
|
||||
|
||||
export interface ChartDownloadOptions {
|
||||
title?: string;
|
||||
legend?: ExportLegendItem[];
|
||||
}
|
||||
|
||||
/** Resolves the page background so exports match the current theme. */
|
||||
function exportBackground(): string {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
@@ -47,18 +58,59 @@ function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartE
|
||||
*/
|
||||
export async function downloadChartsPng(
|
||||
charts: Array<ChartExportItem | ExportableChart | null | undefined>,
|
||||
fileName: string
|
||||
fileName: string,
|
||||
options: ChartDownloadOptions = {}
|
||||
): Promise<void> {
|
||||
const items = charts.map(toItem).filter((it) => it.chart != null);
|
||||
const items = charts
|
||||
.map(toItem)
|
||||
.filter(
|
||||
(it): it is ChartExportItem & { chart: ExportableChart } =>
|
||||
typeof it.chart?.getExportImage === 'function'
|
||||
);
|
||||
if (items.length === 0) return;
|
||||
|
||||
const canvases = (
|
||||
await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title })))
|
||||
).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0);
|
||||
// A supplementary panel must never prevent the remaining charts from being
|
||||
// downloaded. Invalid adapters are filtered above; rejected renders are
|
||||
// isolated here instead of aborting the complete Promise.all chain.
|
||||
const rendered = await Promise.allSettled(
|
||||
items.map((it) => it.chart.getExportImage({ title: it.title }))
|
||||
);
|
||||
const canvases = rendered.flatMap((result) =>
|
||||
result.status === 'fulfilled' &&
|
||||
result.value != null &&
|
||||
result.value.width > 0 &&
|
||||
result.value.height > 0
|
||||
? [result.value]
|
||||
: []
|
||||
);
|
||||
if (canvases.length === 0) return;
|
||||
|
||||
const maxWidth = Math.max(...canvases.map((c) => c.width));
|
||||
const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const padding = 12 * dpr;
|
||||
const gap = 14 * dpr;
|
||||
const rowHeight = 22 * dpr;
|
||||
const measure = document.createElement('canvas').getContext('2d');
|
||||
const legendRows: ExportLegendItem[][] = [];
|
||||
if (measure && options.legend?.length) {
|
||||
measure.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
|
||||
let row: ExportLegendItem[] = [];
|
||||
let rowWidth = 0;
|
||||
for (const item of options.legend) {
|
||||
const width = 18 * dpr + measure.measureText(item.name).width;
|
||||
if (row.length > 0 && rowWidth + gap + width > maxWidth - padding * 2) {
|
||||
legendRows.push(row);
|
||||
row = [];
|
||||
rowWidth = 0;
|
||||
}
|
||||
row.push(item);
|
||||
rowWidth += (rowWidth > 0 ? gap : 0) + width;
|
||||
}
|
||||
if (row.length > 0) legendRows.push(row);
|
||||
}
|
||||
const titleHeight = options.title ? 34 * dpr : 0;
|
||||
const legendHeight = legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0;
|
||||
const totalHeight = titleHeight + canvases.reduce((sum, c) => sum + c.height, 0) + legendHeight;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = maxWidth;
|
||||
@@ -70,11 +122,54 @@ export async function downloadChartsPng(
|
||||
ctx.fillStyle = exportBackground();
|
||||
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
||||
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const foreground = styles.getPropertyValue('--foreground').trim() || '#1f2937';
|
||||
const muted = styles.getPropertyValue('--muted-foreground').trim() || '#6b7280';
|
||||
let y = 0;
|
||||
if (options.title) {
|
||||
ctx.fillStyle = foreground;
|
||||
ctx.font = `600 ${16 * dpr}px system-ui, -apple-system, sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(options.title, padding, 17 * dpr);
|
||||
y += 34 * dpr;
|
||||
}
|
||||
for (const c of canvases) {
|
||||
ctx.drawImage(c, 0, y);
|
||||
y += c.height;
|
||||
}
|
||||
if (legendRows.length > 0) {
|
||||
y += 8 * dpr;
|
||||
ctx.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const row of legendRows) {
|
||||
let x = padding;
|
||||
for (const item of row) {
|
||||
const markerWidth = 12 * dpr;
|
||||
ctx.strokeStyle = item.color;
|
||||
ctx.fillStyle = item.color;
|
||||
ctx.lineWidth = 2 * dpr;
|
||||
ctx.setLineDash(item.style === 'dashed' ? [4 * dpr, 3 * dpr] : []);
|
||||
if (item.style === 'point') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + markerWidth / 2, y + rowHeight / 2, 3 * dpr, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (item.style === 'bar') {
|
||||
ctx.fillRect(x + 3 * dpr, y + 5 * dpr, 6 * dpr, 12 * dpr);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + rowHeight / 2);
|
||||
ctx.lineTo(x + markerWidth, y + rowHeight / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
x += markerWidth + 6 * dpr;
|
||||
ctx.fillStyle = muted;
|
||||
ctx.fillText(item.name, x, y + rowHeight / 2);
|
||||
x += ctx.measureText(item.name).width + gap;
|
||||
}
|
||||
y += rowHeight;
|
||||
}
|
||||
}
|
||||
|
||||
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
|
||||
}
|
||||
|
||||
@@ -9,4 +9,10 @@
|
||||
|
||||
export { default as ChartContainer } from './ChartContainer.svelte';
|
||||
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
||||
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
|
||||
export {
|
||||
downloadChartsPng,
|
||||
type ChartDownloadOptions,
|
||||
type ChartExportItem,
|
||||
type ExportableChart,
|
||||
type ExportLegendItem
|
||||
} from './downloadChartsPng';
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
import { href, routePath } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
|
||||
import SupporterBadge from '$lib/supporter/SupporterBadge.svelte';
|
||||
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
|
||||
|
||||
import SettingsMenu from './settings-menu.svelte';
|
||||
import ThemeIcon from './theme-icon.svelte';
|
||||
@@ -41,6 +42,21 @@
|
||||
if (flagEl && !flagEl.src.endsWith(src)) flagEl.src = src;
|
||||
});
|
||||
|
||||
// Built as a string rather than inline markup: the pieces are optional, and
|
||||
// separators spelled out in the template lose their spacing to Svelte's
|
||||
// whitespace trimming ("Canton of Schwyz,Switzerland").
|
||||
let locationRegion = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
|
||||
// elevation rides along in the pill's muted part ("· Canton of Schwyz,
|
||||
// Switzerland · 465m"); a 0 m coastal town is a real reading, only a
|
||||
// missing value is dropped
|
||||
let locationDetail = $derived(
|
||||
[locationRegion, location?.elevation != null ? `${Math.round(location.elevation)}m` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
// the pill ellipses, so the full name still has to be readable somewhere
|
||||
let locationLine = $derived([location?.name, locationDetail].filter(Boolean).join(' · '));
|
||||
|
||||
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
||||
const themeTitles: Record<Theme, () => string> = {
|
||||
system: m.theme_follow_system,
|
||||
@@ -99,8 +115,10 @@
|
||||
|
||||
<!-- Current location display -->
|
||||
{#if location}
|
||||
<!-- min-w-0 + a ceiling so a long "Sant Pere de Ribes, Catalonia, Spain"
|
||||
ellipses inside the pill instead of pushing the search box off centre -->
|
||||
<div
|
||||
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
|
||||
class="hidden min-w-0 max-w-70 items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex xl:max-w-96"
|
||||
>
|
||||
<img
|
||||
bind:this={flagEl}
|
||||
@@ -109,13 +127,10 @@
|
||||
alt={location.country}
|
||||
/>
|
||||
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
||||
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
|
||||
<span class="min-w-0 truncate text-sm font-semibold text-foreground" title={locationLine}>
|
||||
{location.name}
|
||||
{#if location.admin1 || location.country}
|
||||
<span class="font-normal text-muted-foreground">
|
||||
· {#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}
|
||||
</span>
|
||||
{#if locationDetail}
|
||||
<span class="font-normal text-muted-foreground">· {locationDetail}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -144,8 +159,10 @@
|
||||
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
|
||||
so SupporterBadge still verifies the key on every viewport. -->
|
||||
<div class="hidden items-center gap-3 md:flex">
|
||||
<!-- Supporter status / unlock -->
|
||||
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
|
||||
{#if SUPPORTER_ENABLED}
|
||||
<SupporterBadge />
|
||||
{/if}
|
||||
|
||||
<!-- Language: the locale lives in the URL, so this is a set of links -->
|
||||
<LanguageSelector />
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import SupporterIcon from '$lib/paywall/SupporterIcon.svelte';
|
||||
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import SupporterIcon from '$lib/supporter/SupporterIcon.svelte';
|
||||
import UnlockDialog from '$lib/supporter/UnlockDialog.svelte';
|
||||
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
|
||||
import { isSupporter } from '$lib/supporter/store';
|
||||
|
||||
import ThemeIcon from './theme-icon.svelte';
|
||||
|
||||
@@ -76,6 +77,8 @@
|
||||
|
||||
<LanguageOptions onSelect={() => (open = false)} />
|
||||
|
||||
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
|
||||
{#if SUPPORTER_ENABLED}
|
||||
<div class="border-t border-border/70 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -87,7 +90,9 @@
|
||||
>
|
||||
<SupporterIcon
|
||||
filled={$isSupporter}
|
||||
class="h-4.5 w-4.5 shrink-0 {$isSupporter ? 'text-amber-500' : 'text-muted-foreground'}"
|
||||
class="h-4.5 w-4.5 shrink-0 {$isSupporter
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground'}"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-[13px] font-semibold">
|
||||
@@ -99,6 +104,7 @@
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
+34
-133
@@ -10,6 +10,7 @@
|
||||
* - Efficient binary protobuf transport instead of JSON
|
||||
* - Consistent timestamp and unit handling
|
||||
*/
|
||||
import { Model } from '@openmeteo/sdk/model';
|
||||
import { Unit } from '@openmeteo/sdk/unit';
|
||||
import { fetchWeatherApi } from 'openmeteo';
|
||||
|
||||
@@ -241,7 +242,10 @@ export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams {
|
||||
}
|
||||
|
||||
export interface ModelSeriesData {
|
||||
modelName: string;
|
||||
/** Stable identifier requested by the UI. */
|
||||
modelId: string;
|
||||
/** Concrete model identifier reported by the API (useful for seamless/best-match requests). */
|
||||
resolvedModelId: string;
|
||||
variables: Record<string, number[]>;
|
||||
}
|
||||
|
||||
@@ -535,7 +539,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
||||
* compatible with existing chart utilities.
|
||||
*/
|
||||
export async function fetchModelComparison(
|
||||
params: ModelCompareParams
|
||||
params: ModelCompareParams,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<ModelCompareResult> {
|
||||
const forecastApiParams: Record<string, string | number | undefined> = {
|
||||
latitude: String(params.latitude),
|
||||
@@ -549,14 +554,28 @@ export async function fetchModelComparison(
|
||||
timezone: params.timezone
|
||||
};
|
||||
|
||||
const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
|
||||
const responses = await fetchWeatherApi(
|
||||
FORECAST_URL,
|
||||
forecastApiParams,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ signal: options.signal }
|
||||
);
|
||||
if (responses.length === 0) throw new Error('The weather service returned no model data.');
|
||||
if (responses.length !== params.models.length) {
|
||||
throw new Error(
|
||||
`The weather service returned ${responses.length} model responses for ${params.models.length} requested models.`
|
||||
);
|
||||
}
|
||||
|
||||
// With multiple models, we get one response per model
|
||||
const firstResponse = responses[0];
|
||||
const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
|
||||
const timezone = firstResponse.timezone() ?? params.timezone ?? 'UTC';
|
||||
|
||||
const hourlyBlock = firstResponse.hourly()!;
|
||||
const hourlyBlock = firstResponse.hourly();
|
||||
if (!hourlyBlock) throw new Error('The weather service returned no hourly model data.');
|
||||
const timestamps = getTimestamps(hourlyBlock);
|
||||
|
||||
// Extract sunrise/sunset from the first response's daily block
|
||||
@@ -586,16 +605,22 @@ export async function fetchModelComparison(
|
||||
);
|
||||
hourlyFlat['time'] = timeInUnixSeconds;
|
||||
|
||||
for (const response of responses) {
|
||||
for (const [responseIndex, response] of responses.entries()) {
|
||||
const modelHourly = response.hourly();
|
||||
if (!modelHourly) continue;
|
||||
|
||||
// Determine model name from the response
|
||||
// The multi-model endpoint preserves request order and returns one response
|
||||
// per requested model, including unavailable regional models. Keep that
|
||||
// requested id as the stable UI key. The concrete id reported by Open-Meteo
|
||||
// is metadata only: seamless and best-match requests may resolve to a
|
||||
// different underlying domain.
|
||||
const modelEnum = response.model();
|
||||
const modelName = modelEnumToString(modelEnum);
|
||||
const resolvedModelId = Model[modelEnum] ?? `model_${modelEnum}`;
|
||||
const modelId = params.models[responseIndex] ?? resolvedModelId;
|
||||
|
||||
const modelData: ModelSeriesData = {
|
||||
modelName,
|
||||
modelId,
|
||||
resolvedModelId,
|
||||
variables: {}
|
||||
};
|
||||
|
||||
@@ -608,7 +633,7 @@ export async function fetchModelComparison(
|
||||
modelData.variables[varName] = values;
|
||||
|
||||
// Build flat key like "temperature_2m_icon_seamless"
|
||||
const flatKey = `${varName}_${modelName}`;
|
||||
const flatKey = `${varName}_${modelId}`;
|
||||
hourlyFlat[flatKey] = values;
|
||||
|
||||
// Record unit
|
||||
@@ -786,130 +811,6 @@ export async function fetchEnsembleForecast(
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Model Enum Mapping ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps the SDK Model enum integer to a string model name.
|
||||
* This table must stay in sync with the @openmeteo/sdk Model enum.
|
||||
*/
|
||||
function modelEnumToString(modelEnum: number): string {
|
||||
const modelMap: Record<number, string> = {
|
||||
0: 'undefined',
|
||||
1: 'best_match',
|
||||
2: 'gfs_seamless',
|
||||
3: 'gfs_global',
|
||||
4: 'gfs_hrrr',
|
||||
5: 'meteofrance_seamless',
|
||||
6: 'meteofrance_arpege_seamless',
|
||||
7: 'meteofrance_arpege_world',
|
||||
8: 'meteofrance_arpege_europe',
|
||||
9: 'meteofrance_arome_seamless',
|
||||
10: 'meteofrance_arome_france',
|
||||
11: 'meteofrance_arome_france_hd',
|
||||
12: 'jma_seamless',
|
||||
13: 'jma_msm',
|
||||
14: 'jms_gsm',
|
||||
15: 'jma_gsm',
|
||||
16: 'gem_seamless',
|
||||
17: 'gem_global',
|
||||
18: 'gem_regional',
|
||||
19: 'gem_hrdps_continental',
|
||||
20: 'icon_seamless',
|
||||
21: 'icon_global',
|
||||
22: 'icon_eu',
|
||||
23: 'icon_d2',
|
||||
24: 'ecmwf_ifs04',
|
||||
25: 'metno_nordic',
|
||||
26: 'era5_seamless',
|
||||
27: 'era5',
|
||||
28: 'cerra',
|
||||
29: 'era5_land',
|
||||
30: 'ecmwf_ifs',
|
||||
31: 'gwam',
|
||||
32: 'ewam',
|
||||
33: 'glofas_seamless_v3',
|
||||
34: 'glofas_forecast_v3',
|
||||
35: 'glofas_consolidated_v3',
|
||||
36: 'glofas_seamless_v4',
|
||||
37: 'glofas_forecast_v4',
|
||||
38: 'glofas_consolidated_v4',
|
||||
39: 'gfs025',
|
||||
40: 'gfs05',
|
||||
41: 'CMCC_CM2_VHR4',
|
||||
42: 'FGOALS_f3_H_highresSST',
|
||||
43: 'FGOALS_f3_H',
|
||||
44: 'HiRAM_SIT_HR',
|
||||
45: 'MRI_AGCM3_2_S',
|
||||
46: 'EC_Earth3P_HR',
|
||||
47: 'MPI_ESM1_2_XR',
|
||||
48: 'NICAM16_8S',
|
||||
49: 'cams_europe',
|
||||
50: 'cams_global',
|
||||
51: 'cfsv2',
|
||||
52: 'era5_ocean',
|
||||
53: 'cma_grapes_global',
|
||||
54: 'bom_access_global',
|
||||
55: 'bom_access_global_ensemble',
|
||||
56: 'arpae_cosmo_seamless',
|
||||
57: 'arpae_cosmo_2i',
|
||||
58: 'arpae_cosmo_2i_ruc',
|
||||
59: 'arpae_cosmo_5m',
|
||||
60: 'ecmwf_ifs025',
|
||||
61: 'ecmwf_aifs025',
|
||||
62: 'gfs013',
|
||||
63: 'gfs_graphcast025',
|
||||
64: 'ecmwf_wam025',
|
||||
65: 'meteofrance_wave',
|
||||
66: 'meteofrance_currents',
|
||||
67: 'ecmwf_wam025_ensemble',
|
||||
68: 'ncep_gfswave025',
|
||||
69: 'ncep_gefswave025',
|
||||
70: 'knmi_seamless',
|
||||
71: 'knmi_harmonie_arome_europe',
|
||||
72: 'knmi_harmonie_arome_netherlands',
|
||||
73: 'dmi_seamless',
|
||||
74: 'dmi_harmonie_arome_europe',
|
||||
75: 'metno_seamless',
|
||||
76: 'era5_ensemble',
|
||||
77: 'ecmwf_ifs_analysis',
|
||||
78: 'ecmwf_ifs_long_window',
|
||||
79: 'ecmwf_ifs_analysis_long_window',
|
||||
80: 'ukmo_global_deterministic_10km',
|
||||
81: 'ukmo_uk_deterministic_2km',
|
||||
82: 'ukmo_seamless',
|
||||
83: 'ncep_gfswave016',
|
||||
84: 'ncep_nbm_conus',
|
||||
85: 'ukmo_global_ensemble_20km',
|
||||
86: 'ecmwf_aifs025_single',
|
||||
87: 'jma_jaxa_himawari',
|
||||
88: 'eumetsat_sarah3',
|
||||
89: 'eumetsat_lsa_saf_msg',
|
||||
90: 'eumetsat_lsa_saf_iodc',
|
||||
91: 'satellite_radiation_seamless',
|
||||
92: 'kma_gdps',
|
||||
93: 'kma_ldps',
|
||||
94: 'kma_seamless',
|
||||
95: 'italia_meteo_arpae_icon_2i',
|
||||
96: 'ukmo_uk_ensemble_2km',
|
||||
97: 'meteofrance_arome_france_hd_15min',
|
||||
98: 'meteofrance_arome_france_15min',
|
||||
99: 'meteoswiss_icon_ch1',
|
||||
100: 'meteoswiss_icon_ch2',
|
||||
101: 'meteoswiss_icon_ch1_ensemble',
|
||||
102: 'meteoswiss_icon_ch2_ensemble',
|
||||
103: 'meteoswiss_icon_seamless',
|
||||
104: 'ncep_nam_conus',
|
||||
105: 'icon_d2_ruc',
|
||||
106: 'ecmwf_seas5',
|
||||
107: 'ecmwf_ec46',
|
||||
108: 'ecmwf_seasonal_seamless',
|
||||
109: 'ecmwf_ifs_seamless',
|
||||
110: 'jma_jaxa_mtg_fci',
|
||||
111: 'gem_hrdps_west'
|
||||
};
|
||||
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
||||
}
|
||||
|
||||
// ─── Historical (Archive) Types ─────────────────────────────────────────────
|
||||
|
||||
export interface HistoricalDailyData {
|
||||
|
||||
@@ -10,6 +10,18 @@ import { writable } from 'svelte/store';
|
||||
*/
|
||||
export const pageContentReady = writable(true);
|
||||
|
||||
/**
|
||||
* Raised by the layout just before it captures a navigation away from the maps
|
||||
* page. The map is a cross-origin iframe, which browsers do not paint into a
|
||||
* view transition snapshot - captured bare, the outgoing page would carry a
|
||||
* hole where the map was. The maps page answers by laying an opaque
|
||||
* same-origin cover over the iframe (the same one that hides the map while it
|
||||
* boots), so the snapshot shows a clean panel and the cross-fade has something
|
||||
* real to fade from. The layout lowers it again once the navigation is
|
||||
* through.
|
||||
*/
|
||||
export const mapTransitionCover = writable(false);
|
||||
|
||||
/**
|
||||
* Called by the layout before it swaps to a route that fetches its own data.
|
||||
*
|
||||
@@ -22,6 +34,16 @@ export function markPageLoading(): void {
|
||||
pageContentReady.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The counterpart, for a route that has nothing to wait for (the maps page, the
|
||||
* legal pages). Without it a navigation away from a page that never resolved
|
||||
* would leave the flag stuck on "loading", and the layout's overlay would sit on
|
||||
* top of a page that is perfectly finished.
|
||||
*/
|
||||
export function markPageReady(): void {
|
||||
pageContentReady.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a page's readiness. Pass a getter for "my data has arrived". Only
|
||||
* ever sets the flag - clearing it is the layout's job (see above).
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import SupporterIcon from './SupporterIcon.svelte';
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { isSupporter, refreshSupporter } from './supporter';
|
||||
import { isSupporter, refreshSupporter } from './store';
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import SupporterIcon from './SupporterIcon.svelte';
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
|
||||
import { isSupporter, refreshSupporter, supporterState } from './supporter';
|
||||
import { isSupporter, refreshSupporter, supporterState } from './store';
|
||||
|
||||
interface Props {
|
||||
/** Short feature name shown in the locked panel headline. */
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* The supporter mark. A heart rather than a padlock: nothing here is locked
|
||||
* away as a punishment - the extras are a thank-you for chipping in, and a
|
||||
* padlock framed it as a paywall. Filled once someone is supporting,
|
||||
* padlock framed it as a hard wall. Filled once someone is supporting,
|
||||
* outlined as an invitation before that.
|
||||
*/
|
||||
interface Props {
|
||||
@@ -9,13 +9,7 @@
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { SIGNUP_URL, getSupporterPrice } from './config';
|
||||
import {
|
||||
clearLicense,
|
||||
isSupporter,
|
||||
storedLicenseKey,
|
||||
supporterState,
|
||||
verifyKey
|
||||
} from './supporter';
|
||||
import { clearLicense, isSupporter, storedLicenseKey, supporterState, verifyKey } from './store';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
@@ -1,29 +1,40 @@
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
/**
|
||||
* Paywall configuration.
|
||||
* Supporter configuration.
|
||||
*
|
||||
* The frontend stays fully static and open source; the only server piece is the
|
||||
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
|
||||
* build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a
|
||||
* `.env` file), otherwise the sensible drizz.li defaults are used.
|
||||
* build at your deployment with the `VITE_SUPPORTER_*` env vars (e.g. in a
|
||||
* `.env` file), otherwise the sensible drizz.li defaults are used. The
|
||||
* pre-rename `VITE_PAYWALL_*` names still work.
|
||||
*/
|
||||
|
||||
const env = import.meta.env as Record<string, string | undefined>;
|
||||
|
||||
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* Master switch for the supporter features. While this is `false` everything
|
||||
* supporter-related is parked: the nav entry points are hidden, no key is
|
||||
* verified, and every gated page renders as if the visitor were a supporter.
|
||||
* The logic stays in place — set `VITE_SUPPORTER_ENABLED=true` (or default this
|
||||
* to `true`) to bring it back.
|
||||
*/
|
||||
export const SUPPORTER_ENABLED = env.VITE_SUPPORTER_ENABLED === 'true';
|
||||
|
||||
/** Base URL of the self-hosted verify API (drizzli-paywall). */
|
||||
export const PAYWALL_API_BASE = stripTrailingSlash(
|
||||
env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
|
||||
export const SUPPORTER_API_BASE = stripTrailingSlash(
|
||||
env.VITE_SUPPORTER_API_BASE ?? env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
|
||||
);
|
||||
|
||||
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
|
||||
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
|
||||
/** Where prospective supporters go to sign up (the verify API's signup form). */
|
||||
export const SIGNUP_URL =
|
||||
env.VITE_SUPPORTER_SIGNUP_URL ?? env.VITE_PAYWALL_SIGNUP_URL ?? `${SUPPORTER_API_BASE}/`;
|
||||
|
||||
// ─── Location-based pricing ──────────────────────────────────────────────────
|
||||
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
|
||||
// from their location. Mirror that here so the paywall copy matches. Detection
|
||||
// from their location. Mirror that here so the supporter copy matches. Detection
|
||||
// is timezone/locale based (no network geo lookup) and guarded for SSR.
|
||||
|
||||
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
|
||||
@@ -54,7 +65,7 @@ export function detectCurrency(): SupporterCurrency {
|
||||
return 'EUR';
|
||||
}
|
||||
|
||||
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / Monat". */
|
||||
/** Display price for the locked panel, e.g. "€3 / month" or "CHF 3 / Monat". */
|
||||
export function getSupporterPrice(): string {
|
||||
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
|
||||
// deployment's .env keeps working.
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* This gate is a convenience/honor-system gate: the frontend is open source and
|
||||
* static, so it can be bypassed. Keeping the subscriber list server-side (in the
|
||||
* paywall repo) is what makes it meaningful in practice.
|
||||
* verify API's repo) is what makes it meaningful in practice.
|
||||
*/
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { persisted } from 'svelte-persisted-store';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { PAYWALL_API_BASE } from './config';
|
||||
import { SUPPORTER_API_BASE, SUPPORTER_ENABLED } from './config';
|
||||
|
||||
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
|
||||
export const storedLicenseKey = persisted<string>('license_key', '');
|
||||
@@ -75,10 +75,14 @@ function notExpired(expires: string | null | undefined): boolean {
|
||||
* Whether supporter content should be shown. A live "valid"/"invalid" result
|
||||
* wins; otherwise we fall back to the cached result (so a reload or a brief
|
||||
* network blip doesn't lock a paying user out).
|
||||
*
|
||||
* While supporter features are parked (`SUPPORTER_ENABLED === false`) this is always
|
||||
* true, so every gated page renders unlocked for everyone.
|
||||
*/
|
||||
export const isSupporter = derived(
|
||||
[supporterState, storedSupporterCache],
|
||||
([$state, $cache]): boolean => {
|
||||
if (!SUPPORTER_ENABLED) return true;
|
||||
if ($state.status === 'valid') return true;
|
||||
if ($state.status === 'invalid') return false;
|
||||
return !!($cache && $cache.valid && notExpired($cache.expires));
|
||||
@@ -98,6 +102,10 @@ export interface VerifyResult {
|
||||
* subscription can still show a "renew" state) but the cache is marked invalid.
|
||||
*/
|
||||
export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
// Parked: never touch the verify API (and report success, since everything
|
||||
// is unlocked anyway).
|
||||
if (!SUPPORTER_ENABLED) return { valid: true, expires: null };
|
||||
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
supporterState.set({ status: 'invalid' });
|
||||
@@ -106,7 +114,7 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
|
||||
supporterState.set({ status: 'checking' });
|
||||
try {
|
||||
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||
const res = await fetch(`${SUPPORTER_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||
headers: { accept: 'application/json' }
|
||||
});
|
||||
const data = (await res.json()) as VerifyResult;
|
||||
@@ -135,6 +143,8 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
|
||||
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
|
||||
export async function refreshSupporter(): Promise<void> {
|
||||
if (!SUPPORTER_ENABLED) return;
|
||||
|
||||
const key = get(storedLicenseKey);
|
||||
if (!key) {
|
||||
supporterState.set({ status: 'idle' });
|
||||
+44
-10
@@ -11,12 +11,40 @@ import type { Locale as DateFnsLocale } from 'date-fns';
|
||||
// locale the messages do.
|
||||
const DATE_LOCALES: Record<string, DateFnsLocale> = { en: enGB, de, es, fr, it };
|
||||
|
||||
/**
|
||||
* Normalises anything date-shaped into a plain `Date`, or null when it does not
|
||||
* describe a real instant.
|
||||
*
|
||||
* Two reasons every helper below starts here:
|
||||
*
|
||||
* 1. date-fns copies its input with `new date.constructor(value)`. Hand it the
|
||||
* reactive `SvelteDate` the pages use for the selected day and it builds
|
||||
* *another* SvelteDate, then reads the copy's fields back through memoised
|
||||
* signals - a signal graph per formatted timestamp, in a path that runs
|
||||
* hundreds of times per render.
|
||||
* 2. date-fns throws `RangeError: Invalid time value` on an invalid date. Thrown
|
||||
* from inside a render (or inside a view-transition callback, where it
|
||||
* surfaces as an unhandled rejection) that takes the whole page down for what
|
||||
* is really one unformattable cell.
|
||||
*/
|
||||
function plainDate(date: Date | null | undefined): Date | null {
|
||||
const time = date?.getTime?.();
|
||||
if (time == null || !Number.isFinite(time)) return null;
|
||||
// already a plain Date: no copy needed
|
||||
return date!.constructor === Date ? (date as Date) : new Date(time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
|
||||
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
|
||||
*
|
||||
* Returns '' for a date or zone it cannot format - callers compare these strings
|
||||
* or print them, and both degrade gracefully on an empty one.
|
||||
*/
|
||||
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
|
||||
return formatInTimeZone(date, timeZone, pattern, {
|
||||
const d = plainDate(date);
|
||||
if (!d || !timeZone) return '';
|
||||
return formatInTimeZone(d, timeZone, pattern, {
|
||||
locale: DATE_LOCALES[getLocale()] ?? enGB
|
||||
});
|
||||
}
|
||||
@@ -26,16 +54,20 @@ export function formatZoned(date: Date, timeZone: string, pattern: string): stri
|
||||
* Important for comparing weather forecast days against a selected date.
|
||||
*/
|
||||
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
|
||||
const z1 = toZonedTime(date1, timeZone);
|
||||
const z2 = toZonedTime(date2, timeZone);
|
||||
return isSameDayDateFns(z1, z2);
|
||||
const d1 = plainDate(date1);
|
||||
const d2 = plainDate(date2);
|
||||
if (!d1 || !d2 || !timeZone) return false;
|
||||
return isSameDayDateFns(toZonedTime(d1, timeZone), toZonedTime(d2, timeZone));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the numeric hour (0-23) for a date in a specific timezone.
|
||||
* Gets the numeric hour (0-23) for a date in a specific timezone, or NaN when
|
||||
* the date cannot be read.
|
||||
*/
|
||||
export function getZonedHour(date: Date, timeZone: string): number {
|
||||
return parseInt(formatInTimeZone(date, timeZone, 'H'), 10);
|
||||
const d = plainDate(date);
|
||||
if (!d || !timeZone) return NaN;
|
||||
return parseInt(formatInTimeZone(d, timeZone, 'H'), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,9 +75,11 @@ export function getZonedHour(date: Date, timeZone: string): number {
|
||||
* or a formatted date string, all relative to the target timezone.
|
||||
*/
|
||||
export function getRelativeDayLabel(date: Date, timeZone: string): string {
|
||||
const now = new Date();
|
||||
const zonedDate = toZonedTime(date, timeZone);
|
||||
const zonedNow = toZonedTime(now, timeZone);
|
||||
const d = plainDate(date);
|
||||
if (!d || !timeZone) return '';
|
||||
|
||||
const zonedDate = toZonedTime(d, timeZone);
|
||||
const zonedNow = toZonedTime(new Date(), timeZone);
|
||||
|
||||
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today();
|
||||
|
||||
@@ -57,7 +91,7 @@ export function getRelativeDayLabel(date: Date, timeZone: string): string {
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
|
||||
|
||||
return formatZoned(date, timeZone, 'EEE d MMM');
|
||||
return formatZoned(d, timeZone, 'EEE d MMM');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+47
-11
@@ -1,7 +1,12 @@
|
||||
import { tick } from 'svelte';
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
import {
|
||||
canStartViewTransition,
|
||||
prefersReducedMotion,
|
||||
skipActiveViewTransition,
|
||||
startViewTransition,
|
||||
supportsViewTransitions
|
||||
} from './view-transition';
|
||||
|
||||
/**
|
||||
* Runs a day change inside a view transition, so the outgoing day is still on
|
||||
@@ -12,26 +17,57 @@ const prefersReducedMotion = () =>
|
||||
* `.day-region-*` in routes/layout.css); everything else - the strip, the
|
||||
* header, the page chrome - is pinned by the `day-switch` class so it stays
|
||||
* completely still.
|
||||
*
|
||||
* A day switch that lands while a navigation transition is still on screen just
|
||||
* applies: starting a rival transition would skip the running one and flash the
|
||||
* page (see view-transition.ts).
|
||||
*/
|
||||
/** Guards the shared cleanup below against a switch superseding a switch. */
|
||||
let dayTransitionToken = 0;
|
||||
|
||||
export async function runDayTransition(update: () => void): Promise<void> {
|
||||
if (prefersReducedMotion() || !document.startViewTransition) {
|
||||
if (!canStartViewTransition()) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++dayTransitionToken;
|
||||
const root = document.documentElement;
|
||||
root.classList.add('day-switch');
|
||||
|
||||
// The region snapshots include the part of the table normally scrolled up
|
||||
// behind the sticky strip; the transition overlay is clipped at the bar's
|
||||
// bottom edge so they cannot paint over the (live, clickable) strip.
|
||||
// Measured per switch because the bar's height follows the scroll collapse.
|
||||
const bar = document.querySelector('.daystrip .strip-row');
|
||||
if (bar) {
|
||||
const clip = Math.max(0, bar.getBoundingClientRect().bottom);
|
||||
root.style.setProperty('--day-switch-clip', `${clip}px`);
|
||||
}
|
||||
|
||||
// The page stays scrollable during the fade, but the snapshots and the clip
|
||||
// line above are anchored to where things were at capture - so the first
|
||||
// sign of scrolling finishes the fade on the spot instead of animating
|
||||
// against a moving page.
|
||||
const skip = () => skipActiveViewTransition();
|
||||
window.addEventListener('wheel', skip, { passive: true });
|
||||
window.addEventListener('touchmove', skip, { passive: true });
|
||||
|
||||
try {
|
||||
// Svelte applies the change on the next tick; the transition has to wait
|
||||
// for that before it snapshots the new state.
|
||||
await document.startViewTransition(async () => {
|
||||
// for that before it snapshots the new state. `day-switch` scopes which
|
||||
// regions take part (see routes/layout.css) and is cleared when it ends.
|
||||
await startViewTransition(
|
||||
async () => {
|
||||
update();
|
||||
await tick();
|
||||
}).finished;
|
||||
} catch {
|
||||
/* a superseded transition is fine - the DOM is already up to date */
|
||||
},
|
||||
{ rootClass: 'day-switch' }
|
||||
);
|
||||
} finally {
|
||||
root.classList.remove('day-switch');
|
||||
window.removeEventListener('wheel', skip);
|
||||
window.removeEventListener('touchmove', skip);
|
||||
// a newer switch owns the clip var now; only the last one may clear it
|
||||
if (token === dayTransitionToken) root.style.removeProperty('--day-switch-clip');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +81,7 @@ export function daySwap(node: HTMLElement, key: unknown) {
|
||||
|
||||
const play = () => {
|
||||
// view transitions handle it properly where they exist
|
||||
if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return;
|
||||
if (supportsViewTransitions() || prefersReducedMotion()) return;
|
||||
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
|
||||
duration: 460,
|
||||
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
|
||||
|
||||
@@ -76,6 +76,16 @@ interface ResolveLocationOptions {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocoding results for a route segment, kept for the life of the process.
|
||||
*
|
||||
* A city's coordinates do not change, and the same segment is resolved over and
|
||||
* over: once per per-location route during the prerender (five builds of the
|
||||
* same lookup for every city), and again on every client-side hop from a city's
|
||||
* week page to its comparison or archive.
|
||||
*/
|
||||
const resolvedLocations = new Map<string, GeoLocation>();
|
||||
|
||||
export async function resolveLocationFromRoute({
|
||||
urlLocation,
|
||||
routePrefix,
|
||||
@@ -86,6 +96,11 @@ export async function resolveLocationFromRoute({
|
||||
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
|
||||
}
|
||||
|
||||
// The canonical-path check below still has to run per call (the same city is
|
||||
// reached under different route prefixes), so only the lookup is cached.
|
||||
const cached = resolvedLocations.get(urlLocation);
|
||||
if (cached) return finishResolve(cached, routePrefix, event);
|
||||
|
||||
let urlLocationName: string;
|
||||
let urlLocationId: string | undefined;
|
||||
|
||||
@@ -124,6 +139,15 @@ export async function resolveLocationFromRoute({
|
||||
location = candidate;
|
||||
}
|
||||
|
||||
resolvedLocations.set(urlLocation, location);
|
||||
return finishResolve(location, routePrefix, event);
|
||||
}
|
||||
|
||||
function finishResolve(
|
||||
location: GeoLocation,
|
||||
routePrefix: string,
|
||||
event: ResolveLocationOptions['event']
|
||||
): GeoLocation {
|
||||
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
|
||||
// every path with a trailing slash. Match that here or the equality check
|
||||
// never holds and the redirect loops forever. The comparison also has to
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* automatically once the maps app ships seamless support; until then they fall
|
||||
* back to their widest-coverage member (a regional member could be entirely
|
||||
* off-screen). Models without an entry are tried under their own id; models
|
||||
* the map does not serve at all (best_match, bom, google, UKMO ensembles)
|
||||
* the map does not serve at all (best_match, google, UKMO ensembles)
|
||||
* resolve to null.
|
||||
*/
|
||||
const modelDomainCandidates: Record<string, string[]> = {
|
||||
@@ -46,9 +46,8 @@ const modelDomainCandidates: Record<string, string[]> = {
|
||||
// MeteoSwiss (CH2 covers a wider area than CH1)
|
||||
meteoswiss_icon_seamless: ['meteoswiss_icon_ch2'],
|
||||
|
||||
// KMA Korea (kma_ldps is not served by the maps project)
|
||||
kma_seamless: ['kma_gdps'],
|
||||
kma_ldps: ['kma_gdps'],
|
||||
// CHMI Czech Republic (Central Europe is the widest native domain)
|
||||
chmi_aladin_seamless: ['chmi_aladin_seamless', 'chmi_aladin_central_europe_2km'],
|
||||
|
||||
// JMA Japan
|
||||
jma_seamless: ['jma_seamless', 'jma_gsm'],
|
||||
|
||||
@@ -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
|
||||
* before", not "the previous day I clicked".
|
||||
*
|
||||
* Callers must pass the URL *untracked* (`get(page).url`, not `$page.url`).
|
||||
* Reading it reactively inside the same effect that writes it creates a loop:
|
||||
* replaceState publishes a new URL, the effect re-runs, writes again - which
|
||||
* Svelte eventually kills with `effect_update_depth_exceeded`, hanging the page.
|
||||
* The base is `location`, deliberately not `page.url`. Shallow routing does not
|
||||
* republish the URL: `replaceState` writes the history entry (and files the
|
||||
* *previous* `page.url` in it, so a popstate can restore it) but leaves
|
||||
* `page.url` on the last navigated URL. Diffing against that stale value is
|
||||
* wrong in exactly one direction - clearing a parameter. Opening a day writes
|
||||
* `?day=`, `page.url` still has none, so asking to remove it produces a URL
|
||||
* identical to the stale one, the write is skipped as a no-op, and the
|
||||
* parameter stays in the address bar for good.
|
||||
*
|
||||
* Reading `location` rather than a passed-in URL also removes the old trap that
|
||||
* callers had to pass it untracked: an effect that both read `$page.url` and
|
||||
* wrote to it looped until `effect_update_depth_exceeded` hung the page.
|
||||
*/
|
||||
import { browser } from '$app/environment';
|
||||
import { replaceState } from '$app/navigation';
|
||||
|
||||
export function syncSearchParams(url: URL, updates: Record<string, string | null>): void {
|
||||
export function syncSearchParams(updates: Record<string, string | null>): void {
|
||||
if (!browser) return;
|
||||
const next = new URL(url);
|
||||
const current = new URL(window.location.href);
|
||||
const next = new URL(current);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value == null || value === '') next.searchParams.delete(key);
|
||||
else next.searchParams.set(key, value);
|
||||
}
|
||||
if (next.href === url.href) return;
|
||||
if (next.href === current.href) return;
|
||||
try {
|
||||
replaceState(next, {});
|
||||
} catch {
|
||||
@@ -39,6 +48,30 @@ export function syncSearchParams(url: URL, updates: Record<string, string | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The value to mirror, or null when it is the view's default. Defaults belong
|
||||
* in the code, not the query string: a shared link should carry only what the
|
||||
* visitor actually changed, and going back to the default has to clear the
|
||||
* parameter again rather than pin the default in place.
|
||||
*/
|
||||
export function unlessDefault(value: string | null | undefined, fallback: string): string | null {
|
||||
return value && value !== fallback ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same, for the comma-separated list parameters. Order counts - the models are
|
||||
* plotted, coloured and legended in the order they are listed, so a reordered
|
||||
* line-up is a different view even when it holds the same entries.
|
||||
*/
|
||||
export function listUnlessDefault(
|
||||
values: string[] | null | undefined,
|
||||
fallback: string[]
|
||||
): string | null {
|
||||
if (!values?.length) return null;
|
||||
const joined = values.join(',');
|
||||
return joined === fallback.join(',') ? null : joined;
|
||||
}
|
||||
|
||||
/** Reads a comma-separated list, dropping empties. */
|
||||
export function readList(url: URL, key: string): string[] | null {
|
||||
const raw = url.searchParams.get(key);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Central entry point for every view transition in the app.
|
||||
*
|
||||
* Two things it exists to get right.
|
||||
*
|
||||
* **One transition at a time, but only where it matters.** Starting a
|
||||
* transition while another is running makes the browser skip the first, and
|
||||
* what that looks like depends entirely on which phase the first is in:
|
||||
*
|
||||
* - *capturing* - its update callback has not resolved yet, so the screen is
|
||||
* frozen on the outgoing snapshot. Skipping drops that snapshot on the spot
|
||||
* and pops the half-updated live DOM into view: the whole-screen flash.
|
||||
* This phase is common here, because navigation deliberately holds the
|
||||
* callback open while the new page fetches, and `/` plus every bare
|
||||
* `/weather/<view>/` page is a redirect stub that navigates again from
|
||||
* `onMount` - one click, two or three navigations.
|
||||
* - *animating* - the DOM is already in its final state and the pseudo
|
||||
* elements are playing out. Skipping just finishes them early, landing on
|
||||
* exactly the state they were heading for.
|
||||
*
|
||||
* So a new transition rides along inside the running one only while it is
|
||||
* capturing; once it is animating, superseding it is the better answer (waiting
|
||||
* would strand the new update under a stale snapshot until the animation ends).
|
||||
*
|
||||
* **A scoping class.** `rootClass` is set on `<html>` for the life of the
|
||||
* transition, so the stylesheet can tell a page swap from a day switch and pin
|
||||
* the parts that are identical on both sides (see routes/layout.css).
|
||||
*/
|
||||
type UpdateCallback = () => void | Promise<void>;
|
||||
|
||||
interface Options {
|
||||
/** Class set on `<html>` while the transition runs, for scoping CSS. */
|
||||
rootClass?: string;
|
||||
/**
|
||||
* Set false to run the update without a transition. For content the browser
|
||||
* does not paint into a snapshot - a cross-origin iframe - where animating
|
||||
* means animating a hole rather than a cross-fade.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
|
||||
let capturing: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* How many running transitions hold each scoping class. A superseded
|
||||
* transition's cleanup fires while its successor is mid-capture (skipping
|
||||
* rejects `finished` on a microtask, which runs before the next render);
|
||||
* without the count it would strip the class out from under the successor,
|
||||
* and a day switch captured without `day-switch` falls back to the full-page
|
||||
* fade it exists to prevent.
|
||||
*/
|
||||
const rootClassHolds = new Map<string, number>();
|
||||
|
||||
/** The most recently started transition, while it is capturing or animating. */
|
||||
let active: ViewTransition | null = null;
|
||||
|
||||
/**
|
||||
* Finishes the running transition's animation on the spot (the DOM is already
|
||||
* in its final state, so this is always safe). Used to hand the screen back
|
||||
* the moment the user starts scrolling under a day switch, rather than
|
||||
* animating against a moving target.
|
||||
*/
|
||||
export function skipActiveViewTransition(): void {
|
||||
active?.skipTransition();
|
||||
}
|
||||
|
||||
export const supportsViewTransitions = (): boolean =>
|
||||
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
|
||||
|
||||
export const prefersReducedMotion = (): boolean =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/**
|
||||
* True while a transition is frozen on its outgoing snapshot - the phase in
|
||||
* which starting a rival transition would flash the page.
|
||||
*/
|
||||
export const isViewTransitionCapturing = (): boolean => capturing !== null;
|
||||
|
||||
/** Whether `startViewTransition` would actually open one right now. */
|
||||
export const canStartViewTransition = (): boolean =>
|
||||
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionCapturing();
|
||||
|
||||
/**
|
||||
* Runs `update` inside a view transition, or straight away when one cannot (or
|
||||
* must not) be started. Resolves once the transition has finished animating -
|
||||
* or as soon as the update is done, when it ran on its own.
|
||||
*/
|
||||
export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
|
||||
const { rootClass, enabled = true } = options;
|
||||
|
||||
if (!enabled || !canStartViewTransition()) {
|
||||
return Promise.resolve(update()).then(
|
||||
() => {},
|
||||
(error: unknown) => {
|
||||
console.error('view transition update failed', error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
if (rootClass) {
|
||||
rootClassHolds.set(rootClass, (rootClassHolds.get(rootClass) ?? 0) + 1);
|
||||
root.classList.add(rootClass);
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(update);
|
||||
active = transition;
|
||||
|
||||
// A throw inside the callback rejects `updateCallbackDone` as well as
|
||||
// `finished`. Nothing awaits the former, and an unhandled rejection there is
|
||||
// what turns one bad render into an "Uncaught" error on the page.
|
||||
const captured = transition.updateCallbackDone.then(
|
||||
() => {},
|
||||
(error: unknown) => {
|
||||
console.error('view transition update failed', error);
|
||||
}
|
||||
);
|
||||
capturing = captured;
|
||||
void captured.then(() => {
|
||||
if (capturing === captured) capturing = null;
|
||||
});
|
||||
|
||||
// A superseded transition rejects `finished`; the DOM is already up to date,
|
||||
// so that is not an error worth surfacing.
|
||||
return transition.finished
|
||||
.then(
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
.finally(() => {
|
||||
if (active === transition) active = null;
|
||||
if (!rootClass) return;
|
||||
const holds = (rootClassHolds.get(rootClass) ?? 1) - 1;
|
||||
if (holds > 0) {
|
||||
rootClassHolds.set(rootClass, holds);
|
||||
} else {
|
||||
rootClassHolds.delete(rootClass);
|
||||
root.classList.remove(rootClass);
|
||||
}
|
||||
});
|
||||
}
|
||||
+224
-59
@@ -6,16 +6,26 @@
|
||||
import { afterNavigate, onNavigate } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
|
||||
import {
|
||||
mapTransitionCover,
|
||||
markPageLoading,
|
||||
markPageReady,
|
||||
pageContentReady
|
||||
} from '$lib/stores/page-transition.svelte';
|
||||
import { storedTheme } from '$lib/stores/settings';
|
||||
|
||||
import {
|
||||
canStartViewTransition,
|
||||
startViewTransition,
|
||||
supportsViewTransitions
|
||||
} from '$lib/utils/view-transition';
|
||||
|
||||
import Footer from '$lib/components/navigation/footer.svelte';
|
||||
import Header from '$lib/components/navigation/header.svelte';
|
||||
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
||||
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
import { routePath } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import './layout.css';
|
||||
|
||||
@@ -31,7 +41,9 @@
|
||||
const apply = () => {
|
||||
const root = document.documentElement;
|
||||
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
|
||||
const paint = () => root.classList.toggle('dark', dark);
|
||||
const paint = () => {
|
||||
root.classList.toggle('dark', dark);
|
||||
};
|
||||
|
||||
// The very first application is just painting the stored theme - only
|
||||
// an actual switch afterwards is worth cross-fading.
|
||||
@@ -42,9 +54,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.startViewTransition) {
|
||||
if (canStartViewTransition() && !onMapsPage()) {
|
||||
// one cross-fade of the whole document; component transitions untouched
|
||||
document.startViewTransition(paint);
|
||||
void startViewTransition(paint);
|
||||
return;
|
||||
}
|
||||
|
||||
// A navigation transition already owns the screen: repaint under it
|
||||
// rather than skipping it (which would flash), and fall back to the
|
||||
// colour transition below when the browser has none at all.
|
||||
if (supportsViewTransitions()) {
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,25 +83,91 @@
|
||||
// ── Page cross-fade ───────────────────────────────────────────────────────
|
||||
// A real cross-fade needs the outgoing page still on screen while the
|
||||
// incoming one appears - so the view transition is opened at navigation and
|
||||
// deliberately held open until the new page reports that its data has landed.
|
||||
// The browser keeps showing the old snapshot for that whole time (rather than
|
||||
// flashing a skeleton), then fades it into the finished page.
|
||||
// held open for a short grace period, waiting for the new page to report that
|
||||
// its data has landed. A cached or fast response lands inside that window and
|
||||
// the old page fades straight into the finished one, no skeleton in between.
|
||||
//
|
||||
// The wait is capped: past the ceiling we cross-fade into whatever is on
|
||||
// screen instead of freezing the UI on a slow network.
|
||||
const READY_CEILING_MS = 2200;
|
||||
const FADE_OUT_MS = 170;
|
||||
// Past the grace period the wait gives up and the loading overlay takes over:
|
||||
// holding the old page frozen any longer looks like a dead click, and the
|
||||
// overlay is the honest answer - something is happening, it just isn't here
|
||||
// yet. Note the order: the overlay has to be in the DOM *before* the
|
||||
// transition captures the incoming state, because a running view transition
|
||||
// freezes the page and nothing painted after that point can appear.
|
||||
//
|
||||
// There is deliberately no fade for browsers without view transitions. The
|
||||
// old fallback dimmed the outgoing page to nothing and brought the new one
|
||||
// back up, which on a single layer is a flash of the bare background rather
|
||||
// than a cross-fade. Swapping outright and letting the overlay carry the
|
||||
// "loading" message is quieter and honest.
|
||||
const READY_HOLD_MS = 350;
|
||||
// Nothing reports ready when a fetch fails outright, so the overlay needs its
|
||||
// own way out rather than sitting on top of an error message forever.
|
||||
const OVERLAY_CEILING_MS = 15000;
|
||||
|
||||
let contentVisible = $state(true);
|
||||
let revealTimer = 0;
|
||||
// The map is a cross-origin iframe, and a browser does not paint one into a
|
||||
// view transition snapshot - captured bare, any transition with the maps page
|
||||
// on either side would animate a hole where the map is. The way out is to
|
||||
// make sure the map is never what gets captured: the maps page keeps an
|
||||
// opaque cover over the iframe while the map boots (so an arrival fades into
|
||||
// a clean panel, and the map fades up once ready), and raises the same cover
|
||||
// again just before a departure is captured (see mapTransitionCover). Both
|
||||
// snapshots then hold real pixels and the maps page transitions like any
|
||||
// other route.
|
||||
const MAPS_ROUTE = '/weather/maps';
|
||||
const onMapsPage = () => routePath(get(page).url.pathname).startsWith(MAPS_ROUTE);
|
||||
|
||||
const reducedMotion = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
let loadingOverlay = $state(false);
|
||||
let overlayCeilingTimer = 0;
|
||||
// A view transition freezes the page, so a Svelte in-transition started under
|
||||
// one cannot play - the overlay would be captured at opacity 0 and pop in
|
||||
// afterwards. Inside a transition the cross-fade does the fading instead.
|
||||
let overlayFadesIn = $state(true);
|
||||
|
||||
// Routes that fetch their own forecast after mounting. Knowing this up front
|
||||
// is what makes the wait reliable: the layout clears the ready flag before
|
||||
// the swap rather than trusting the incoming page to have done it.
|
||||
const DATA_ROUTES = new Set([
|
||||
function showOverlay(animate: boolean): void {
|
||||
overlayFadesIn = animate;
|
||||
loadingOverlay = true;
|
||||
clearTimeout(overlayCeilingTimer);
|
||||
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
|
||||
}
|
||||
|
||||
function hideOverlay(): void {
|
||||
clearTimeout(overlayCeilingTimer);
|
||||
loadingOverlay = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual dismissal. The overlay reports on a fetch it does not control, so
|
||||
* "stuck" is always a possibility (a page that never reports ready, a request
|
||||
* that neither resolves nor rejects) - and the page behind it still works.
|
||||
* Whatever was loading carries on; only the veil goes.
|
||||
*/
|
||||
function dismissOverlay(): void {
|
||||
hideOverlay();
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape' && loadingOverlay) dismissOverlay();
|
||||
}
|
||||
|
||||
/** The page that just mounted has its data: whatever we were waiting for is in. */
|
||||
$effect(() => {
|
||||
if ($pageContentReady) hideOverlay();
|
||||
});
|
||||
|
||||
// Routes that are not finished on arrival: the five that fetch a forecast
|
||||
// after mounting, the maps page (whose map is a cross-origin iframe that has
|
||||
// to load first), and the redirect stubs, which render nothing at all and
|
||||
// bounce to a located URL from `onMount`. Knowing this up front is what makes
|
||||
// the wait reliable - the layout clears the ready flag before the swap rather
|
||||
// than trusting the incoming page to have done it.
|
||||
const PENDING_ROUTES = new Set([
|
||||
'/',
|
||||
'/weather/week',
|
||||
'/weather/14-day',
|
||||
'/weather/compare',
|
||||
'/weather/seasonal',
|
||||
'/weather/historical',
|
||||
'/weather/maps',
|
||||
'/weather/week/[location]',
|
||||
'/weather/14-day/[location]',
|
||||
'/weather/compare/[location]',
|
||||
@@ -89,25 +175,67 @@
|
||||
'/weather/historical/[location]'
|
||||
]);
|
||||
|
||||
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
|
||||
async function waitForContent(): Promise<void> {
|
||||
const deadline = Date.now() + READY_CEILING_MS;
|
||||
/**
|
||||
* Resolves once the freshly mounted page has its data, or once the grace
|
||||
* period is up - in which case the overlay goes up first, so it is part of
|
||||
* the state the transition is about to snapshot.
|
||||
*/
|
||||
async function waitForContent(underTransition: boolean): Promise<void> {
|
||||
const deadline = Date.now() + READY_HOLD_MS;
|
||||
while (!get(pageContentReady) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
if (!get(pageContentReady)) showOverlay(!underTransition);
|
||||
// one more frame so the page paints its data before the snapshot is taken
|
||||
await tick();
|
||||
}
|
||||
|
||||
onNavigate((navigation) => {
|
||||
if (reducedMotion()) return;
|
||||
/**
|
||||
* True when the navigation lands on the route and params the page is already
|
||||
* showing - the sidebar's home link from the week page it points at, or a
|
||||
* link that differs only in the query string.
|
||||
*
|
||||
* SvelteKit keeps the page component mounted for those, and nothing it holds
|
||||
* changes: its forecast is already fetched, so the readiness effect it
|
||||
* registered never re-runs and never re-announces. Clearing the flag for such
|
||||
* a navigation strands it cleared, and the overlay sits there until its
|
||||
* ceiling. There is genuinely nothing to wait for, so don't clear it.
|
||||
*/
|
||||
function landsOnCurrentPage(navigation: {
|
||||
from: { route: { id: string | null }; params: Record<string, string> | null } | null;
|
||||
to: { route: { id: string | null }; params: Record<string, string> | null } | null;
|
||||
}): boolean {
|
||||
const from = navigation.from;
|
||||
const to = navigation.to;
|
||||
if (!from?.route.id || from.route.id !== to?.route.id) return false;
|
||||
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
|
||||
}
|
||||
|
||||
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
|
||||
if (loadsData) markPageLoading();
|
||||
onNavigate(async (navigation) => {
|
||||
const pending =
|
||||
PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
|
||||
// Either way the flag is set explicitly: leaving a page that never resolved
|
||||
// for one that has nothing to load would otherwise strand the overlay.
|
||||
if (pending) markPageLoading();
|
||||
else markPageReady();
|
||||
|
||||
// `startViewTransition` decides whether a transition is possible at all
|
||||
// (support, reduced motion, one already capturing) and runs the update
|
||||
// inline when it is not - so there is exactly one path from here down.
|
||||
const underTransition = canStartViewTransition();
|
||||
|
||||
// Leaving the maps page: cover the iframe before the outgoing state is
|
||||
// captured, so the snapshot holds a clean panel instead of a hole where
|
||||
// the map was. The tick makes sure the cover is actually in the DOM by
|
||||
// the time the capture reads it.
|
||||
if (underTransition && navigation.from?.route?.id === MAPS_ROUTE) {
|
||||
mapTransitionCover.set(true);
|
||||
await tick();
|
||||
}
|
||||
|
||||
if (typeof document.startViewTransition === 'function') {
|
||||
return new Promise<void>((swap) => {
|
||||
document.startViewTransition(async () => {
|
||||
void startViewTransition(
|
||||
async () => {
|
||||
// hand control back so SvelteKit swaps the DOM underneath the
|
||||
// frozen snapshot of the old page
|
||||
swap();
|
||||
@@ -115,40 +243,28 @@
|
||||
// rejects this promise; that is not an error worth surfacing,
|
||||
// and leaving it unhandled shows up as "navigation aborted".
|
||||
await navigation.complete.catch(() => {});
|
||||
if (loadsData) await waitForContent();
|
||||
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);
|
||||
|
||||
afterNavigate((navigation) => {
|
||||
// The departure snapshot (if any) is taken by now, so the maps cover has
|
||||
// done its job; lowering it here also means a later visit to the maps page
|
||||
// starts from its own boot cover rather than a stuck one.
|
||||
mapTransitionCover.set(false);
|
||||
|
||||
// The page scrolls inside <main>, not the window, so SvelteKit's own scroll
|
||||
// handling never touches it and a new page would open half way down.
|
||||
// Back/forward and in-page anchors keep their position.
|
||||
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
|
||||
mainEl?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
|
||||
contentVisible = true;
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const reveal = () => {
|
||||
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
|
||||
contentVisible = true;
|
||||
return;
|
||||
}
|
||||
revealTimer = window.setTimeout(reveal, 60);
|
||||
};
|
||||
reveal();
|
||||
});
|
||||
|
||||
// the maps page embeds a full-bleed map: no padding, no scrolling
|
||||
@@ -171,14 +287,66 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<!-- the icon itself lives in app.html, so the SPA fallback carries it too -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
|
||||
indicator, not a modal, so the nav and the search stay usable while a slow
|
||||
forecast is still on its way. It is also always dismissable - Escape or the
|
||||
close button - because a veil nobody can get rid of is worse than no veil,
|
||||
and the page underneath is perfectly usable either way. -->
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
{#if loadingOverlay}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
|
||||
in:fade={{ duration: overlayFadesIn ? 120 : 0 }}
|
||||
out:fade={{ duration: 280 }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="pointer-events-auto absolute top-3 right-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:bg-muted hover:text-foreground md:top-4 md:right-4"
|
||||
onclick={dismissOverlay}
|
||||
aria-label={m.page_loading_dismiss()}
|
||||
title={m.page_loading_dismiss()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold">{m.page_loading()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<!-- Desktop sidebar -->
|
||||
<div class="hidden h-full shrink-0 md:block">
|
||||
<div class="sidebar-region hidden h-full shrink-0 md:block">
|
||||
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
|
||||
</div>
|
||||
|
||||
@@ -221,10 +389,7 @@
|
||||
{:else}
|
||||
<!-- cap the content width on very large screens; the footer below
|
||||
gives the page its ending, so only modest bottom room is needed -->
|
||||
<div
|
||||
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
|
||||
class:page-fade-hidden={!contentVisible}
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
|
||||
{@render children()}
|
||||
</div>
|
||||
<!-- full-bleed footer inside the scroll area (its own inner max-w),
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
Drizz.li ist kostenlos nutzbar. Wenn es Ihnen nützt, können Sie es mit einem kleinen monatlichen
|
||||
Beitrag unterstützen (ab 3 € / Monat) - das hilft, Domain, Hosting und Entwicklungszeit zu
|
||||
decken. Als Dankeschön schalten Unterstützerinnen und Unterstützer die Extras frei: historisches
|
||||
Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie erscheinen.
|
||||
Wetter mit Vergleich zu den Klimanormalen, die saisonalen Aussichten für die kommenden Monate
|
||||
sowie neue Funktionen, sobald sie erscheinen.
|
||||
</p>
|
||||
<p>
|
||||
Beiträge laufen über eine einfache Banküberweisung - kein Zahlungsdienstleister, keine
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
<p>
|
||||
Drizz.li is free to use. If you find it useful, you can support it with a small monthly
|
||||
contribution (from €3 / month) - it helps cover the domain, hosting and development time. As a
|
||||
thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, and
|
||||
new supporter features as they land.
|
||||
thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, the
|
||||
seasonal outlook for the months ahead, and new supporter features as they land.
|
||||
</p>
|
||||
<p>
|
||||
Contributions are handled by a simple bank transfer - no payment processor, no stored card
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
Drizz.li es gratuito. Si te resulta útil, puedes apoyarlo con una pequeña aportación mensual
|
||||
(desde 3 € al mes): ayuda a cubrir el dominio, el alojamiento y el tiempo de desarrollo. Como
|
||||
agradecimiento, quienes colaboran desbloquean los extras: clima histórico con comparación frente
|
||||
a las normales climáticas y las nuevas funciones que vayan llegando.
|
||||
a las normales climáticas, la perspectiva estacional de los próximos meses y las nuevas
|
||||
funciones que vayan llegando.
|
||||
</p>
|
||||
<p>
|
||||
Las aportaciones se gestionan mediante una simple transferencia bancaria: sin pasarela de pago y
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
Drizz.li est gratuit. S'il vous est utile, vous pouvez le soutenir par une petite contribution
|
||||
mensuelle (à partir de 3 € par mois) : elle aide à couvrir le nom de domaine, l'hébergement et
|
||||
le temps de développement. En remerciement, les contributeurs débloquent les bonus : la météo
|
||||
historique avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir.
|
||||
historique avec comparaison aux normales climatiques, l'aperçu saisonnier des mois à venir,
|
||||
ainsi que les nouvelles fonctions à venir.
|
||||
</p>
|
||||
<p>
|
||||
Les contributions passent par un simple virement bancaire : aucun prestataire de paiement,
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
Drizz.li è gratuito. Se lo trovi utile, puoi sostenerlo con un piccolo contributo mensile (da 3
|
||||
€ al mese): aiuta a coprire dominio, hosting e tempo di sviluppo. Come ringraziamento, chi
|
||||
sostiene il progetto sblocca gli extra: meteo storico con confronto rispetto alle normali
|
||||
climatiche e le nuove funzioni man mano che arrivano.
|
||||
climatiche, le prospettive stagionali per i mesi a venire e le nuove funzioni man mano che
|
||||
arrivano.
|
||||
</p>
|
||||
<p>
|
||||
I contributi avvengono con un semplice bonifico bancario: nessun gestore di pagamenti, nessun
|
||||
|
||||
+95
-54
@@ -133,28 +133,20 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
|
||||
page reports its data has arrived. */
|
||||
.page-fade {
|
||||
transition: opacity 320ms ease;
|
||||
}
|
||||
.page-fade-hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-fade {
|
||||
transition: none;
|
||||
}
|
||||
.page-fade-hidden {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Day switching ────────────────────────────────────────────────────────
|
||||
Only the three regions whose content depends on the selected day take part
|
||||
in the cross-fade. Everything else keeps its pixels: the `day-switch`
|
||||
class cancels the root animation, so the strip, header and page chrome do
|
||||
not so much as flicker while the table, summary and charts swap over. */
|
||||
in the cross-fade - and nothing else is captured at all. A captured
|
||||
element is neither painted nor hit-testable in the live page for the
|
||||
length of the animation (per spec: as if it had visibility:hidden and
|
||||
pointer-events:none), and the root is captured by default, which captures
|
||||
the whole page. That is what made the strip unclickable and the page
|
||||
unscrollable while a switch played - no amount of pointer-events on the
|
||||
overlay could fix it, because the page underneath was gone too. With the
|
||||
root left un-named the strip, the chrome and the scroller keep their live
|
||||
pixels and stay fully interactive. */
|
||||
:root.day-switch {
|
||||
view-transition-name: none;
|
||||
}
|
||||
:root.day-switch .day-region-table {
|
||||
view-transition-name: day-table;
|
||||
}
|
||||
@@ -165,26 +157,87 @@
|
||||
view-transition-name: day-charts;
|
||||
}
|
||||
|
||||
/* The sticky day strip never changes during a day switch, but it has to be
|
||||
captured too: view-transition snapshots all paint in one layer above the
|
||||
page, so without its own group (and a higher z-index in that layer) the
|
||||
fading table and charts would slide over the top of it. */
|
||||
:root.day-switch .daystrip {
|
||||
view-transition-name: daystrip;
|
||||
}
|
||||
::view-transition-group(daystrip) {
|
||||
z-index: 20;
|
||||
/* Region snapshots paint in a viewport-fixed layer above the live page, at
|
||||
their full layout size - including the part of the table normally
|
||||
scrolled up behind the sticky strip and the topbar. The strip used to be
|
||||
captured purely to stay on top of that, but a captured strip is dead to
|
||||
input; instead the whole overlay is clipped at the strip bar's bottom
|
||||
edge (measured per switch by runDayTransition), so the snapshots stay
|
||||
out of the chrome and the chrome stays live. */
|
||||
:root.day-switch::view-transition {
|
||||
clip-path: inset(var(--day-switch-clip, 0px) 0 0 0);
|
||||
}
|
||||
|
||||
/* Same for the topbar: a captured region's snapshot is painted at its layout
|
||||
position, including the part normally scrolled up behind the chrome. */
|
||||
:root.day-switch .topbar {
|
||||
/* Page swaps capture the topbar and sidebar: a region that is identical on
|
||||
both sides should not be animated at all, so they are pinned here and
|
||||
swapped outright below. */
|
||||
:root.page-switch .topbar {
|
||||
view-transition-name: topbar;
|
||||
}
|
||||
::view-transition-group(topbar) {
|
||||
z-index: 30;
|
||||
}
|
||||
:root.page-switch .sidebar-region {
|
||||
view-transition-name: sidebar;
|
||||
}
|
||||
::view-transition-group(sidebar) {
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.day-region-table,
|
||||
.day-region-summary,
|
||||
.day-region-charts,
|
||||
.topbar,
|
||||
.sidebar-region {
|
||||
view-transition-name: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The snapshot layer is a fixed overlay across the whole viewport and it
|
||||
hit-tests, so for the length of an animation every click would land on it
|
||||
and die - a day tapped while the previous switch is still fading simply
|
||||
went dead. Let input fall through to the live page instead: by the time
|
||||
the pseudo elements exist the DOM is already in its final state, and a
|
||||
click that starts a new transition supersedes the running one cleanly
|
||||
(see view-transition.ts). */
|
||||
::view-transition {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Cross-fades that do not dip ──────────────────────────────────────────
|
||||
The default cross-fade is NOT opacity-neutral: coverage of two stacked
|
||||
layers is `new + old * (1 - new)`, so at the midpoint the pair covers only
|
||||
~75% of the region and the page background shows through both. That dip is
|
||||
the flash - over the whole viewport for a page swap, and over the table,
|
||||
summary and charts (between them the whole content column) for a day
|
||||
switch.
|
||||
|
||||
Two ways out, and which one applies depends on whether the *incoming*
|
||||
snapshot is opaque. Holding the outgoing one at full opacity fixes the
|
||||
coverage arithmetic, but a translucent incoming snapshot then reads
|
||||
straight through it and the old content lingers as a ghost. */
|
||||
|
||||
/* The document background is opaque, so the whole-viewport swap can simply
|
||||
hold the outgoing snapshot until the pseudo elements are torn down. */
|
||||
@keyframes vt-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: vt-fade-in 400ms ease;
|
||||
}
|
||||
|
||||
/* The day regions are not opaque - they are cards with gaps and headings
|
||||
between them - so they keep a real cross-fade and fix the dip the other
|
||||
way: `plus-lighter` makes the two halves sum to exactly the original
|
||||
wherever they agree, which is most of the region (the card backgrounds are
|
||||
the same on both days; only the readings differ). */
|
||||
::view-transition-old(day-table),
|
||||
::view-transition-new(day-table),
|
||||
::view-transition-old(day-summary),
|
||||
@@ -193,32 +246,20 @@
|
||||
::view-transition-new(day-charts) {
|
||||
animation-duration: 420ms;
|
||||
animation-timing-function: ease;
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
:root.day-switch::view-transition-old(root),
|
||||
:root.day-switch::view-transition-new(root) {
|
||||
/* Pinned chrome swaps outright: identical on both sides, so any animation
|
||||
would only risk a flicker. */
|
||||
::view-transition-old(topbar),
|
||||
::view-transition-old(sidebar) {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.day-region-table,
|
||||
.day-region-summary,
|
||||
.day-region-charts,
|
||||
.daystrip,
|
||||
.topbar {
|
||||
view-transition-name: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme changes cross-fade the whole document in one pass (see
|
||||
routes/+layout.svelte). Doing it as a view transition instead of a blanket
|
||||
`* { transition }` matters: that blanket rule also stretched every
|
||||
component's own hover and focus transitions to 400ms for the duration of
|
||||
the switch, which read as lag on interactive controls. */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 400ms;
|
||||
animation-timing-function: ease;
|
||||
::view-transition-new(topbar),
|
||||
::view-transition-new(sidebar) {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Fallback for browsers without view transitions: fade the colours only. */
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
<h2>1. Was Sie erhalten</h2>
|
||||
<p>
|
||||
Ein Unterstützerbeitrag schaltet die Extras für den bezahlten Zeitraum frei: derzeit
|
||||
historisches Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie
|
||||
erscheinen. Die kostenlosen Teile von Drizz.li bleiben für alle frei.
|
||||
historisches Wetter mit Vergleich zu den Klimanormalen und die saisonalen Aussichten für die
|
||||
kommenden Monate sowie neue Funktionen, sobald sie erscheinen. Die kostenlosen Teile von
|
||||
Drizz.li bleiben für alle frei.
|
||||
</p>
|
||||
|
||||
<h2>2. Ablauf</h2>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<h2>1. What you get</h2>
|
||||
<p>
|
||||
A supporter contribution unlocks the supporter extras for the paid period: currently historical
|
||||
weather with climate-normal comparisons, plus new supporter features as they land. The free
|
||||
parts of Drizz.li stay free for everyone.
|
||||
weather with climate-normal comparisons and the seasonal outlook for the months ahead, plus new
|
||||
supporter features as they land. The free parts of Drizz.li stay free for everyone.
|
||||
</p>
|
||||
|
||||
<h2>2. How it works</h2>
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
<h2>1. Qué obtienes</h2>
|
||||
<p>
|
||||
Una aportación desbloquea los extras durante el periodo pagado: actualmente el clima histórico
|
||||
con comparación frente a las normales climáticas, además de las nuevas funciones que vayan
|
||||
llegando. Las partes gratuitas de Drizz.li siguen siendo gratuitas para todos.
|
||||
con comparación frente a las normales climáticas y la perspectiva estacional de los próximos
|
||||
meses, además de las nuevas funciones que vayan llegando. Las partes gratuitas de Drizz.li
|
||||
siguen siendo gratuitas para todos.
|
||||
</p>
|
||||
|
||||
<h2>2. Cómo funciona</h2>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<h2>1. Ce que vous obtenez</h2>
|
||||
<p>
|
||||
Une contribution débloque les bonus pour la période payée : actuellement la météo historique
|
||||
avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir. Les
|
||||
parties gratuites de Drizz.li le restent pour tout le monde.
|
||||
avec comparaison aux normales climatiques et l'aperçu saisonnier des mois à venir, ainsi que les
|
||||
nouvelles fonctions à venir. Les parties gratuites de Drizz.li le restent pour tout le monde.
|
||||
</p>
|
||||
|
||||
<h2>2. Fonctionnement</h2>
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
<h2>1. Cosa ottieni</h2>
|
||||
<p>
|
||||
Un contributo sblocca gli extra per il periodo pagato: attualmente il meteo storico con
|
||||
confronto rispetto alle normali climatiche, oltre alle nuove funzioni man mano che arrivano. Le
|
||||
parti gratuite di Drizz.li restano gratuite per tutti.
|
||||
confronto rispetto alle normali climatiche e le prospettive stagionali per i mesi a venire,
|
||||
oltre alle nuove funzioni man mano che arrivano. Le parti gratuite di Drizz.li restano gratuite
|
||||
per tutti.
|
||||
</p>
|
||||
|
||||
<h2>2. Come funziona</h2>
|
||||
|
||||
@@ -42,6 +42,21 @@
|
||||
let subtitle = $derived(
|
||||
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
|
||||
);
|
||||
|
||||
// Joined here rather than in the markup: Svelte trims the whitespace around a
|
||||
// line break, so a separator written as "{admin1},\n{country}" renders as
|
||||
// "Canton of Schwyz,Switzerland". Elevation joins the same line; from lg up
|
||||
// the whole line is hidden, because the topbar pill carries the region and
|
||||
// elevation there.
|
||||
let region = $derived(
|
||||
[
|
||||
location?.admin1,
|
||||
location?.country,
|
||||
location?.elevation != null ? `${Math.round(location.elevation)}m` : null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if subtitle && location}
|
||||
@@ -55,13 +70,11 @@
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||
{location.name}
|
||||
<span class="font-medium text-muted-foreground">· {subtitle}</span>
|
||||
</h1>
|
||||
<p class="truncate text-sm text-muted-foreground">
|
||||
<span class="lg:hidden"
|
||||
>{#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||
>{subtitle}
|
||||
</p>
|
||||
{#if region}
|
||||
<p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import { syncSearchParams } from '$lib/utils/url-state';
|
||||
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
@@ -44,7 +46,7 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => fetchedData != null);
|
||||
reportPageReady(() => fetchedData != null || loadError != null);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
@@ -56,10 +58,12 @@
|
||||
storedLocation.set(data.location);
|
||||
});
|
||||
|
||||
const DEFAULT_MODEL = 'ncep_gefs_seamless';
|
||||
|
||||
let params = $state({
|
||||
...defaultParameters,
|
||||
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
|
||||
models: ['ncep_gefs_seamless']
|
||||
models: [DEFAULT_MODEL]
|
||||
});
|
||||
|
||||
// units live in a persisted store; mirror them into params so a change
|
||||
@@ -94,7 +98,7 @@
|
||||
$effect(() => {
|
||||
const model = params.models?.[0];
|
||||
if (!mounted || !model) return;
|
||||
syncSearchParams(get(page).url, { model });
|
||||
syncSearchParams({ model: unlessDefault(model, DEFAULT_MODEL) });
|
||||
});
|
||||
|
||||
// components persist across refetches; entries are null while unmounted
|
||||
@@ -294,9 +298,9 @@
|
||||
|
||||
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
|
||||
{#snippet heroActions()}
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
|
||||
<div class="flex w-full items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||
selectedModel={params.models?.[0] ?? DEFAULT_MODEL}
|
||||
groups={ensembleModelGroups}
|
||||
label={m.model_ensemble()}
|
||||
onModelChange={(model) => {
|
||||
@@ -340,6 +344,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
|
||||
<div class="relative">
|
||||
{#if fetchedData}
|
||||
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
||||
the page margins (padded), the graphs bleed to the edges -->
|
||||
@@ -374,8 +380,16 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
|
||||
<div in:fade={{ duration: 200 }} out:skeletonOut>
|
||||
<ChartContainer
|
||||
loading
|
||||
chartCount={params.hourly?.length || 1}
|
||||
chartHeight={340}
|
||||
bleed={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
interface OptionGroup {
|
||||
value: string;
|
||||
label: string;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mode: 'models' | 'variables';
|
||||
groups: OptionGroup[];
|
||||
selected: string[];
|
||||
onToggle: (value: string) => void;
|
||||
onToggleGroup: (values: string[], select: boolean) => void;
|
||||
onRestoreDefaults?: () => void;
|
||||
}
|
||||
|
||||
let { mode, groups, selected, onToggle, onToggleGroup, onRestoreDefaults }: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
let onlySelected = $state(false);
|
||||
let normalizedSearch = $derived(search.trim().toLocaleLowerCase());
|
||||
|
||||
let visibleGroups = $derived.by(() =>
|
||||
groups.flatMap((group) => {
|
||||
const options = group.options.filter((option) => {
|
||||
if (onlySelected && !selected.includes(option.value)) return false;
|
||||
if (!normalizedSearch) return true;
|
||||
return `${group.label} ${option.label} ${option.value} ${option.metadata ?? ''}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearch);
|
||||
});
|
||||
return options.length > 0 ? [{ ...group, options }] : [];
|
||||
})
|
||||
);
|
||||
|
||||
function groupSelection(group: OptionGroup): { count: number; all: boolean; partial: boolean } {
|
||||
const count = group.options.filter((option) => selected.includes(option.value)).length;
|
||||
return {
|
||||
count,
|
||||
all: count === group.options.length,
|
||||
partial: count > 0 && count < group.options.length
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-3 overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm">
|
||||
<div class="border-b border-border/70 bg-muted/20 p-3 sm:p-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder={mode === 'models' ? m.compare_search_models() : m.compare_search_variables()}
|
||||
aria-label={mode === 'models' ? m.compare_search_models() : m.compare_search_variables()}
|
||||
/>
|
||||
<div
|
||||
class="flex min-h-9 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border bg-background px-3 text-xs font-semibold hover:bg-muted"
|
||||
>
|
||||
<Checkbox id="panel_{mode}_only_selected" bind:checked={onlySelected} />
|
||||
<Label for="panel_{mode}_only_selected" class="cursor-pointer text-xs">
|
||||
{m.compare_only_selected()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 sm:p-4">
|
||||
{#if visibleGroups.length > 0}
|
||||
<div class="columns-1 gap-3 md:columns-2">
|
||||
{#each visibleGroups as visibleGroup (visibleGroup.value)}
|
||||
{@const fullGroup =
|
||||
groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup}
|
||||
{@const selection = groupSelection(fullGroup)}
|
||||
<section
|
||||
class="mb-3 inline-block w-full break-inside-avoid overflow-hidden rounded-xl border border-border/70 bg-background align-top"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-11 items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
id="panel_{mode}_group_{fullGroup.value}"
|
||||
checked={selection.all}
|
||||
indeterminate={selection.partial}
|
||||
onCheckedChange={() =>
|
||||
onToggleGroup(
|
||||
fullGroup.options.map((option) => option.value),
|
||||
!selection.all
|
||||
)}
|
||||
/>
|
||||
<Label
|
||||
for="panel_{mode}_group_{fullGroup.value}"
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm font-bold"
|
||||
>
|
||||
{fullGroup.label}
|
||||
</Label>
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{selection.count}/{fullGroup.options.length}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{#each visibleGroup.options as option (option.value)}
|
||||
<div class="flex min-h-10 items-center rounded-lg px-1.5 hover:bg-muted/60">
|
||||
<Checkbox
|
||||
id="panel_{mode}_{option.value}"
|
||||
checked={selected.includes(option.value)}
|
||||
onCheckedChange={() => onToggle(option.value)}
|
||||
/>
|
||||
<Label
|
||||
for="panel_{mode}_{option.value}"
|
||||
class="min-w-0 flex-1 cursor-pointer py-2 pl-2"
|
||||
>
|
||||
<span class="block text-sm font-medium">{option.label}</span>
|
||||
{#if option.metadata}
|
||||
<span class="block text-[10px] text-muted-foreground">
|
||||
{option.metadata}
|
||||
</span>
|
||||
{/if}
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{m.compare_no_matching_options()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if mode === 'variables' && onRestoreDefaults}
|
||||
<div class="border-t border-border bg-muted/20 px-3 py-3 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-9 rounded-lg border border-border bg-background px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
||||
onclick={onRestoreDefaults}
|
||||
>
|
||||
{m.compare_restore_defaults()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,154 +1,774 @@
|
||||
<script lang="ts">
|
||||
import { formatZoned, getZonedHour } from '$lib/utils/date';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
import { formatZoned } from '$lib/utils/date';
|
||||
|
||||
import {
|
||||
groupHover,
|
||||
groupRange,
|
||||
registerGroupMember,
|
||||
setGroupHover,
|
||||
setGroupRange
|
||||
} from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { getWeatherIconName, hasWeatherIcon } from '../../utils/weather-codes';
|
||||
import { modelColor, modelLabel } from './comparison';
|
||||
|
||||
import type { ModelSeriesData } from '$lib/services/weather';
|
||||
|
||||
interface Props {
|
||||
timestamps: number[];
|
||||
hourlyFlat: Record<string, number[]>;
|
||||
models: string[];
|
||||
models: ModelSeriesData[];
|
||||
modelOrder: string[];
|
||||
sunrise: number[];
|
||||
sunset: number[];
|
||||
timezone: string;
|
||||
group: string;
|
||||
plotInsetLeft: number;
|
||||
plotInsetRight: number;
|
||||
showModelNames: boolean;
|
||||
onToggleModelNames: () => void;
|
||||
registerExporter?: (exporter: TimelineExporter | null) => void;
|
||||
}
|
||||
|
||||
let { timestamps, hourlyFlat, models, sunrise, sunset, timezone }: Props = $props();
|
||||
|
||||
let hourlyInterval = $state<1 | 3>(3);
|
||||
|
||||
let filteredIndices = $derived(
|
||||
timestamps.reduce<number[]>((acc, ts, i) => {
|
||||
if (hourlyInterval === 1 || getZonedHour(new Date(ts), timezone) % 3 === 0) {
|
||||
acc.push(i);
|
||||
interface TimelineExporter {
|
||||
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
|
||||
interface TimelinePoint {
|
||||
index: number;
|
||||
time: number;
|
||||
x: number;
|
||||
hour: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface DaySeparator {
|
||||
x: number;
|
||||
}
|
||||
|
||||
interface SunlightSegment {
|
||||
x1: number;
|
||||
x2: number;
|
||||
}
|
||||
|
||||
interface TooltipRow {
|
||||
modelId: string;
|
||||
label: string;
|
||||
color: string;
|
||||
condition: string;
|
||||
cloudCover: number | null;
|
||||
}
|
||||
|
||||
let {
|
||||
timestamps,
|
||||
models,
|
||||
modelOrder,
|
||||
sunrise,
|
||||
sunset,
|
||||
timezone,
|
||||
group,
|
||||
plotInsetLeft,
|
||||
plotInsetRight,
|
||||
showModelNames,
|
||||
onToggleModelNames,
|
||||
registerExporter
|
||||
}: Props = $props();
|
||||
|
||||
const HOUR = 3600;
|
||||
const MIN_SPAN = 2 * HOUR;
|
||||
const HEADER_H = 38;
|
||||
const ROW_H = 38;
|
||||
const FOOTER_H = 5;
|
||||
const ICON_SIZE = 20;
|
||||
const ICON_BOX_WIDTH = 20;
|
||||
const ICON_BOX_HEIGHT = 30;
|
||||
const MIN_ICON_GAP = 20;
|
||||
const SUNLIGHT_STRIP_H = 4;
|
||||
const EXPORT_TITLE_H = 30;
|
||||
const SAMPLE_STEPS = [1, 2, 3, 4, 6, 8, 12, 24] as const;
|
||||
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let width = $state(0);
|
||||
let themeVersion = $state(0);
|
||||
let dragSelect = $state<{ x0: number; x1: number } | null>(null);
|
||||
let screenRenderVersion = 0;
|
||||
|
||||
let timestampsSec = $derived(timestamps.map((timestamp) => timestamp / 1000));
|
||||
let displayModels = $derived(
|
||||
models.filter((model) => model.variables.weather_code?.some(hasWeatherIcon))
|
||||
);
|
||||
|
||||
function checkNewDay(i: number, ts: number): boolean {
|
||||
if (i === 0) return false;
|
||||
const prevTs = timestamps[filteredIndices[i - 1]];
|
||||
return (
|
||||
formatZoned(new Date(ts), timezone, 'd') !== formatZoned(new Date(prevTs), timezone, 'd')
|
||||
let sharedRange = $derived(groupRange(group));
|
||||
let sharedHover = $derived(groupHover(group));
|
||||
let tMin = $derived(timestampsSec[0] ?? 0);
|
||||
let tMax = $derived(
|
||||
timestampsSec.length > 1 ? timestampsSec[timestampsSec.length - 1] : tMin + HOUR
|
||||
);
|
||||
let viewStart = $derived(sharedRange ? Math.max(tMin, sharedRange.start) : tMin);
|
||||
let viewEnd = $derived(
|
||||
sharedRange ? Math.max(viewStart + MIN_SPAN / 2, Math.min(tMax, sharedRange.end)) : tMax
|
||||
);
|
||||
let zoomed = $derived(sharedRange !== null && viewEnd - viewStart < tMax - tMin);
|
||||
let plotWidth = $derived(Math.max(1, width - plotInsetLeft - plotInsetRight));
|
||||
let height = $derived(HEADER_H + displayModels.length * ROW_H + FOOTER_H);
|
||||
|
||||
function xForTime(time: number): number {
|
||||
return plotInsetLeft + ((time - viewStart) / Math.max(1, viewEnd - viewStart)) * plotWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if it's daytime for each timestamp based on sunrise/sunset data.
|
||||
*/
|
||||
let allDaytimeFlags = $derived(
|
||||
timestamps.map((ts) => {
|
||||
const tsS = ts / 1000;
|
||||
function timeForX(x: number): number {
|
||||
return viewStart + ((x - plotInsetLeft) / Math.max(1, plotWidth)) * (viewEnd - viewStart);
|
||||
}
|
||||
|
||||
function clampPlotX(x: number): number {
|
||||
return Math.max(plotInsetLeft, Math.min(width - plotInsetRight, x));
|
||||
}
|
||||
|
||||
function applyRange(start: number, end: number): void {
|
||||
const fullSpan = tMax - tMin;
|
||||
const span = Math.min(Math.max(end - start, MIN_SPAN), fullSpan);
|
||||
if (span >= fullSpan) {
|
||||
setGroupRange(group, null);
|
||||
return;
|
||||
}
|
||||
const boundedStart = Math.max(tMin, Math.min(start, tMax - span));
|
||||
setGroupRange(group, { start: boundedStart, end: boundedStart + span });
|
||||
}
|
||||
|
||||
function zoomAt(centerTime: number, factor: number): void {
|
||||
const span = viewEnd - viewStart;
|
||||
const nextSpan = span * factor;
|
||||
const fraction = (centerTime - viewStart) / Math.max(1, span);
|
||||
applyRange(centerTime - fraction * nextSpan, centerTime + (1 - fraction) * nextSpan);
|
||||
}
|
||||
|
||||
function nearestIndex(values: number[], target: number): number {
|
||||
let low = 0;
|
||||
let high = values.length - 1;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1;
|
||||
if (values[middle] < target) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
if (low > 0 && Math.abs(values[low - 1] - target) <= Math.abs(values[low] - target)) {
|
||||
return low - 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function isDaytime(time: number): boolean {
|
||||
for (let i = 0; i < sunrise.length; i++) {
|
||||
const s = sunrise[i];
|
||||
const e = sunset[i];
|
||||
// Between sunrise and sunset of the same day
|
||||
if (tsS >= s && tsS < e) return true;
|
||||
// Between sunset of day i and sunrise of day i+1 (night)
|
||||
const nextS = sunrise[i + 1] || Infinity;
|
||||
if (tsS >= e && tsS < nextS) return false;
|
||||
if (time >= sunrise[i] && time < sunset[i]) return true;
|
||||
const nextSunrise = sunrise[i + 1] ?? Infinity;
|
||||
if (time >= sunset[i] && time < nextSunrise) return false;
|
||||
}
|
||||
return sunrise.length === 0 || time >= sunrise[0];
|
||||
}
|
||||
|
||||
let sampleStepHours = $derived.by(() => {
|
||||
const pixelsPerHour = plotWidth / Math.max(1, (viewEnd - viewStart) / HOUR);
|
||||
return SAMPLE_STEPS.find((step) => step * pixelsPerHour >= MIN_ICON_GAP) ?? 24;
|
||||
});
|
||||
|
||||
let visiblePoints = $derived.by((): TimelinePoint[] => {
|
||||
const points: TimelinePoint[] = [];
|
||||
const edgeInset = ICON_SIZE / 2 + 1;
|
||||
const firstValidIndex = timestampsSec.findIndex((time) => {
|
||||
const x = xForTime(time);
|
||||
return time >= viewStart && time <= viewEnd && x >= plotInsetLeft + edgeInset;
|
||||
});
|
||||
if (firstValidIndex < 0) return points;
|
||||
|
||||
const firstTime = timestampsSec[firstValidIndex];
|
||||
const canvasRight = width - edgeInset;
|
||||
let previousDay = '';
|
||||
for (let index = firstValidIndex; index < timestampsSec.length; index++) {
|
||||
const time = timestampsSec[index];
|
||||
if (time < viewStart || time > viewEnd) continue;
|
||||
const x = xForTime(time);
|
||||
if (x > canvasRight) break;
|
||||
const hoursFromFirst = Math.round((time - firstTime) / HOUR);
|
||||
if (hoursFromFirst % sampleStepHours !== 0) continue;
|
||||
const date = new Date(timestamps[index]);
|
||||
const day = formatZoned(date, timezone, 'yyyy-MM-dd');
|
||||
points.push({
|
||||
index,
|
||||
time,
|
||||
x,
|
||||
hour: formatZoned(date, timezone, 'HH'),
|
||||
date: day === previousDay ? '' : formatZoned(date, timezone, 'EEE d')
|
||||
});
|
||||
previousDay = day;
|
||||
}
|
||||
return points;
|
||||
});
|
||||
|
||||
let daySeparators = $derived.by((): DaySeparator[] => {
|
||||
const separators: DaySeparator[] = [];
|
||||
let previousDay = '';
|
||||
for (let index = 0; index < timestampsSec.length; index++) {
|
||||
const time = timestampsSec[index];
|
||||
if (time < viewStart || time > viewEnd) continue;
|
||||
const day = formatZoned(new Date(timestamps[index]), timezone, 'yyyy-MM-dd');
|
||||
if (previousDay && day !== previousDay) separators.push({ x: xForTime(time) });
|
||||
previousDay = day;
|
||||
}
|
||||
return separators;
|
||||
});
|
||||
let sunlightSegments = $derived.by((): SunlightSegment[] => {
|
||||
const segments: SunlightSegment[] = [];
|
||||
for (let index = 0; index < sunrise.length; index++) {
|
||||
const start = Math.max(viewStart, sunrise[index]);
|
||||
const end = Math.min(viewEnd, sunset[index] ?? sunrise[index]);
|
||||
if (end <= start) continue;
|
||||
segments.push({ x1: xForTime(start), x2: xForTime(end) });
|
||||
}
|
||||
return segments;
|
||||
});
|
||||
|
||||
let hoverX = $derived(
|
||||
sharedHover !== null && sharedHover >= viewStart && sharedHover <= viewEnd
|
||||
? xForTime(sharedHover)
|
||||
: null
|
||||
);
|
||||
let hoverIndex = $derived(
|
||||
sharedHover !== null && hoverX !== null && timestampsSec.length > 0
|
||||
? nearestIndex(timestampsSec, sharedHover)
|
||||
: -1
|
||||
);
|
||||
|
||||
function conditionLabel(code: number): string {
|
||||
if (code === 0) return m.cond_clear();
|
||||
if (code === 1 || code === 2) return m.cond_fair();
|
||||
if (code === 3) return m.cond_cloudy();
|
||||
if (code === 45 || code === 48) return m.cond_fog();
|
||||
if ([51, 53, 55, 56, 57].includes(code)) return m.cond_drizzle();
|
||||
if ([71, 73, 75, 77, 85, 86].includes(code)) return m.cond_snow();
|
||||
if ([95, 96, 99].includes(code)) return m.cond_thunder();
|
||||
return m.cond_rain();
|
||||
}
|
||||
|
||||
let tooltipRows = $derived.by((): TooltipRow[] => {
|
||||
if (hoverIndex < 0) return [];
|
||||
const rows: TooltipRow[] = [];
|
||||
for (const model of displayModels) {
|
||||
const code = model.variables.weather_code?.[hoverIndex];
|
||||
if (!hasWeatherIcon(code)) continue;
|
||||
const cloudCover = model.variables.cloud_cover?.[hoverIndex];
|
||||
rows.push({
|
||||
modelId: model.modelId,
|
||||
label: modelLabel(model.modelId),
|
||||
color: modelColor(model.modelId, modelOrder),
|
||||
condition: conditionLabel(code),
|
||||
cloudCover:
|
||||
cloudCover !== undefined && Number.isFinite(cloudCover)
|
||||
? Math.round(Math.max(0, Math.min(100, cloudCover)))
|
||||
: null
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
let tooltipVisible = $derived(hoverIndex >= 0 && tooltipRows.length > 0 && hoverX !== null);
|
||||
let tooltipFlip = $derived((hoverX ?? 0) > width * 0.55);
|
||||
let hoverFrame = 0;
|
||||
let queuedClientX = 0;
|
||||
const pointers = new Map<number, { x: number; y: number }>();
|
||||
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
|
||||
let selectionStartX = 0;
|
||||
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
|
||||
let panStart: { x: number; start: number; end: number } | null = null;
|
||||
let pinchStart: { distance: number; start: number; end: number } | null = null;
|
||||
|
||||
onMount(() => {
|
||||
const unregisterGroup = registerGroupMember(group);
|
||||
const interactionElement = containerEl;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) width = entry.contentRect.width;
|
||||
});
|
||||
if (interactionElement) observer.observe(interactionElement);
|
||||
interactionElement?.addEventListener('wheel', handleWheel, { passive: false });
|
||||
|
||||
const themeObserver = new MutationObserver(() => themeVersion++);
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme']
|
||||
});
|
||||
|
||||
registerExporter?.({ getExportImage });
|
||||
return () => {
|
||||
interactionElement?.removeEventListener('wheel', handleWheel);
|
||||
observer.disconnect();
|
||||
themeObserver.disconnect();
|
||||
unregisterGroup();
|
||||
registerExporter?.(null);
|
||||
};
|
||||
});
|
||||
|
||||
function scheduleHover(clientX: number): void {
|
||||
queuedClientX = clientX;
|
||||
if (hoverFrame !== 0) return;
|
||||
hoverFrame = requestAnimationFrame(() => {
|
||||
hoverFrame = 0;
|
||||
if (!containerEl || timestampsSec.length === 0) return;
|
||||
const localX = queuedClientX - containerEl.getBoundingClientRect().left;
|
||||
const time =
|
||||
viewStart + ((localX - plotInsetLeft) / Math.max(1, plotWidth)) * (viewEnd - viewStart);
|
||||
setGroupHover(
|
||||
group,
|
||||
localX >= plotInsetLeft && localX <= width - plotInsetRight
|
||||
? timestampsSec[nearestIndex(timestampsSec, time)]
|
||||
: null
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent): void {
|
||||
if (!containerEl) return;
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (pointers.size === 2) {
|
||||
containerEl.setPointerCapture(event.pointerId);
|
||||
const [first, second] = [...pointers.values()];
|
||||
pinchStart = {
|
||||
distance: Math.max(10, Math.abs(first.x - second.x)),
|
||||
start: viewStart,
|
||||
end: viewEnd
|
||||
};
|
||||
panStart = null;
|
||||
dragSelect = null;
|
||||
gesture = 'pinch';
|
||||
setGroupHover(group, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.pointerType === 'mouse') {
|
||||
containerEl.setPointerCapture(event.pointerId);
|
||||
selectionStartX = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
|
||||
dragSelect = null;
|
||||
gesture = 'select';
|
||||
event.preventDefault();
|
||||
} else {
|
||||
touchStart = { x: event.clientX, y: event.clientY, start: viewStart, end: viewEnd };
|
||||
gesture = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent): void {
|
||||
if (!containerEl) return;
|
||||
if (pointers.has(event.pointerId)) {
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
}
|
||||
|
||||
if (gesture === 'pinch' && pointers.size === 2 && pinchStart) {
|
||||
const [first, second] = [...pointers.values()];
|
||||
const distance = Math.max(10, Math.abs(first.x - second.x));
|
||||
const scale = pinchStart.distance / distance;
|
||||
const span = pinchStart.end - pinchStart.start;
|
||||
const center = (pinchStart.start + pinchStart.end) / 2;
|
||||
applyRange(center - (span * scale) / 2, center + (span * scale) / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture === 'select' && event.pointerType === 'mouse' && pointers.size === 1) {
|
||||
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
|
||||
if (dragSelect || Math.abs(x - selectionStartX) >= 3) {
|
||||
dragSelect = { x0: selectionStartX, x1: x };
|
||||
setGroupHover(group, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
|
||||
const deltaX = Math.abs(event.clientX - touchStart.x);
|
||||
const deltaY = Math.abs(event.clientY - touchStart.y);
|
||||
if (deltaX < 6 && deltaY < 6) return;
|
||||
if (deltaY > deltaX) {
|
||||
gesture = 'scroll';
|
||||
return;
|
||||
}
|
||||
gesture = zoomed ? 'pan' : 'inspect';
|
||||
containerEl.setPointerCapture(event.pointerId);
|
||||
if (gesture === 'pan') {
|
||||
panStart = { x: event.clientX, start: touchStart.start, end: touchStart.end };
|
||||
}
|
||||
}
|
||||
|
||||
if (gesture === 'scroll') return;
|
||||
if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) {
|
||||
const deltaTime =
|
||||
((panStart.x - event.clientX) / Math.max(1, plotWidth)) * (panStart.end - panStart.start);
|
||||
applyRange(panStart.start + deltaTime, panStart.end + deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointers.size <= 1) scheduleHover(event.clientX);
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent): void {
|
||||
if (gesture === 'select' && event.pointerType === 'mouse' && dragSelect) {
|
||||
const first = timeForX(dragSelect.x0);
|
||||
const second = timeForX(dragSelect.x1);
|
||||
applyRange(Math.min(first, second), Math.max(first, second));
|
||||
}
|
||||
dragSelect = null;
|
||||
pointers.delete(event.pointerId);
|
||||
if (pointers.size < 2) pinchStart = null;
|
||||
if (pointers.size === 0) {
|
||||
panStart = null;
|
||||
touchStart = null;
|
||||
gesture = 'none';
|
||||
}
|
||||
if (event.pointerType !== 'mouse') setGroupHover(group, null);
|
||||
}
|
||||
|
||||
function handlePointerLeave(): void {
|
||||
if (pointers.size === 0) setGroupHover(group, null);
|
||||
}
|
||||
|
||||
function handleWheel(event: WheelEvent): void {
|
||||
if ((!event.ctrlKey && !event.metaKey) || !containerEl) return;
|
||||
event.preventDefault();
|
||||
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
|
||||
zoomAt(timeForX(x), event.deltaY < 0 ? 1 / 1.3 : 1.3);
|
||||
}
|
||||
|
||||
function handleDoubleClick(): void {
|
||||
setGroupRange(group, null);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (timestampsSec.length === 0) return;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
const current = sharedHover ?? viewStart;
|
||||
const index = nearestIndex(timestampsSec, current);
|
||||
const delta = event.key === 'ArrowLeft' ? -1 : 1;
|
||||
const next = Math.max(0, Math.min(timestampsSec.length - 1, index + delta));
|
||||
setGroupHover(group, timestampsSec[next]);
|
||||
event.preventDefault();
|
||||
} else if (event.key === '+' || event.key === '=') {
|
||||
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1 / 1.3);
|
||||
event.preventDefault();
|
||||
} else if (event.key === '-' || event.key === '_') {
|
||||
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1.3);
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'Escape') {
|
||||
setGroupRange(group, null);
|
||||
setGroupHover(group, null);
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
|
||||
});
|
||||
|
||||
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||
|
||||
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
|
||||
const key = `${name}|${color}`;
|
||||
let promise = iconImageCache.get(key);
|
||||
if (!promise) {
|
||||
promise = fetch(`/images/weather-icons/${name}.svg`)
|
||||
.then((response) => response.text())
|
||||
.then(
|
||||
(svg) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const colored = svg.replace(/<svg\b/, `<svg fill="${color}"`);
|
||||
const url = URL.createObjectURL(new Blob([colored], { type: 'image/svg+xml' }));
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = (error) => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(error);
|
||||
};
|
||||
image.src = url;
|
||||
})
|
||||
);
|
||||
iconImageCache.set(key, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function getExportImage(opts?: {
|
||||
title?: string;
|
||||
}): Promise<HTMLCanvasElement | null> {
|
||||
if (!containerEl || width <= 0 || displayModels.length === 0) return null;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const titleHeight = opts?.title ? EXPORT_TITLE_H : 0;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(width * dpr);
|
||||
canvas.height = Math.round((height + titleHeight) * dpr);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return null;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const styles = getComputedStyle(containerEl);
|
||||
const color = (name: string, fallback: string): string =>
|
||||
styles.getPropertyValue(name).trim() || fallback;
|
||||
const background = color('--card', '#ffffff');
|
||||
const foreground = color('--foreground', '#1f2937');
|
||||
const muted = color('--muted-foreground', '#6b7280');
|
||||
const grid = color('--border', 'rgba(0, 0, 0, 0.12)');
|
||||
const sunlight = color('--chart-3', '#f6c453');
|
||||
|
||||
context.fillStyle = background;
|
||||
context.fillRect(0, 0, width, height + titleHeight);
|
||||
if (opts?.title) {
|
||||
context.fillStyle = foreground;
|
||||
context.font = '600 14px system-ui, -apple-system, sans-serif';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText(opts.title, 4, EXPORT_TITLE_H / 2 + 2);
|
||||
}
|
||||
|
||||
const yOffset = titleHeight;
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(plotInsetLeft, yOffset, plotWidth, height);
|
||||
context.clip();
|
||||
const sunlightY = yOffset + HEADER_H - SUNLIGHT_STRIP_H;
|
||||
context.fillStyle = muted;
|
||||
context.globalAlpha = 0.12;
|
||||
context.fillRect(plotInsetLeft, sunlightY, plotWidth, SUNLIGHT_STRIP_H);
|
||||
context.fillStyle = sunlight;
|
||||
context.globalAlpha = 0.9;
|
||||
for (const segment of sunlightSegments) {
|
||||
context.fillRect(segment.x1, sunlightY, segment.x2 - segment.x1, SUNLIGHT_STRIP_H);
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
context.strokeStyle = grid;
|
||||
context.lineWidth = 1;
|
||||
for (const separator of daySeparators) {
|
||||
context.beginPath();
|
||||
context.moveTo(separator.x, yOffset);
|
||||
context.lineTo(separator.x, yOffset + height);
|
||||
context.stroke();
|
||||
}
|
||||
context.restore();
|
||||
|
||||
context.font = '600 10px system-ui, -apple-system, sans-serif';
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
for (const point of visiblePoints) {
|
||||
if (point.date) {
|
||||
context.fillStyle = foreground;
|
||||
context.textAlign = 'left';
|
||||
context.fillText(point.date, Math.max(plotInsetLeft + 3, point.x + 3), yOffset + 9);
|
||||
}
|
||||
context.fillStyle = muted;
|
||||
context.textAlign = 'center';
|
||||
context.fillText(point.hour, point.x, yOffset + 27);
|
||||
}
|
||||
|
||||
const iconNames = new Set<string>();
|
||||
for (const model of displayModels) {
|
||||
for (const point of visiblePoints) {
|
||||
const code = model.variables.weather_code?.[point.index];
|
||||
if (!hasWeatherIcon(code)) continue;
|
||||
iconNames.add(getWeatherIconName(code, isDaytime(point.time)));
|
||||
}
|
||||
}
|
||||
const images = new Map<string, HTMLImageElement>();
|
||||
await Promise.all(
|
||||
[...iconNames].map(async (name) => {
|
||||
try {
|
||||
images.set(name, await loadColoredIcon(name, foreground));
|
||||
} catch {
|
||||
/* An individual missing icon should not prevent the report export. */
|
||||
}
|
||||
// Fallback: before the first sunrise
|
||||
if (sunrise.length > 0 && tsS < sunrise[0]) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Filters models that actually have weather_code data available in the response.
|
||||
*/
|
||||
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
|
||||
context.font = '600 11px system-ui, -apple-system, sans-serif';
|
||||
context.textBaseline = 'middle';
|
||||
for (let row = 0; row < displayModels.length; row++) {
|
||||
const model = displayModels[row];
|
||||
const rowTop = yOffset + HEADER_H + row * ROW_H;
|
||||
const rowCenter = rowTop + ROW_H / 2;
|
||||
context.strokeStyle = grid;
|
||||
context.globalAlpha = 0.55;
|
||||
context.beginPath();
|
||||
context.moveTo(0, rowTop);
|
||||
context.lineTo(width, rowTop);
|
||||
context.stroke();
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = modelColor(model.modelId, modelOrder);
|
||||
context.beginPath();
|
||||
context.arc(9, rowCenter, 4, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (showModelNames) {
|
||||
context.fillStyle = foreground;
|
||||
context.textAlign = 'left';
|
||||
context.fillText(
|
||||
modelLabel(model.modelId),
|
||||
18,
|
||||
rowCenter,
|
||||
Math.max(20, plotInsetLeft - 24)
|
||||
);
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.beginPath();
|
||||
// The right chart inset is empty display space, so pictograms at the
|
||||
// final chart coordinate may extend into it without being clipped.
|
||||
context.rect(plotInsetLeft, rowTop, width - plotInsetLeft, ROW_H);
|
||||
context.clip();
|
||||
for (const point of visiblePoints) {
|
||||
const code = model.variables.weather_code?.[point.index];
|
||||
if (!hasWeatherIcon(code)) continue;
|
||||
const cloudCover = model.variables.cloud_cover?.[point.index];
|
||||
const name = getWeatherIconName(code, isDaytime(point.time));
|
||||
const image = images.get(name);
|
||||
if (cloudCover !== undefined && Number.isFinite(cloudCover)) {
|
||||
const fraction = Math.max(0, Math.min(100, cloudCover)) / 100;
|
||||
context.fillStyle = muted;
|
||||
context.globalAlpha = 0.03 + 0.34 * Math.pow(fraction, 0.85);
|
||||
context.beginPath();
|
||||
context.roundRect(
|
||||
point.x - ICON_BOX_WIDTH / 2,
|
||||
rowCenter - ICON_BOX_HEIGHT / 2,
|
||||
ICON_BOX_WIDTH,
|
||||
ICON_BOX_HEIGHT,
|
||||
4
|
||||
);
|
||||
context.fill();
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
if (image) {
|
||||
context.drawImage(
|
||||
image,
|
||||
point.x - ICON_SIZE / 2,
|
||||
rowCenter - ICON_SIZE / 2,
|
||||
ICON_SIZE,
|
||||
ICON_SIZE
|
||||
);
|
||||
}
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// The visible panel is drawn through the exact same export renderer. This
|
||||
// keeps icon sampling, cloud-cover boxes, labels, and plot geometry identical in
|
||||
// the page and downloaded PNG.
|
||||
$effect(() => {
|
||||
const target = canvasEl;
|
||||
const currentWidth = width;
|
||||
const currentHeight = height;
|
||||
void themeVersion;
|
||||
void visiblePoints;
|
||||
void daySeparators;
|
||||
void sunlightSegments;
|
||||
void displayModels;
|
||||
void showModelNames;
|
||||
if (!target || currentWidth <= 0 || currentHeight <= 0) return;
|
||||
|
||||
const version = ++screenRenderVersion;
|
||||
void getExportImage().then((rendered) => {
|
||||
if (!rendered || version !== screenRenderVersion || target !== canvasEl) return;
|
||||
target.width = rendered.width;
|
||||
target.height = rendered.height;
|
||||
const context = target.getContext('2d');
|
||||
if (!context) return;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, target.width, target.height);
|
||||
context.drawImage(rendered, 0, 0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (version === screenRenderVersion) screenRenderVersion++;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet weatherIcon(name: string, size: number = 24)}
|
||||
<svg class="inline-block fill-foreground" width={size} height={size}>
|
||||
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#if displayModels.length > 0}
|
||||
<div class="mt-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold">{m.compare_timeline_title()}</h3>
|
||||
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<span class="select-none text-muted-foreground">3h</span>
|
||||
<section
|
||||
class="mt-7 -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"
|
||||
aria-labelledby="model-pictogram-title"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 px-3 pt-3 pb-2 lg:px-4">
|
||||
<div class="min-w-0">
|
||||
<h2 id="model-pictogram-title" class="text-sm font-bold tracking-tight">
|
||||
{m.compare_timeline_title()}
|
||||
</h2>
|
||||
<p class="text-xs text-muted-foreground">{m.compare_timeline_hint()}</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
||||
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
||||
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
||||
title={m.interval_toggle()}
|
||||
type="button"
|
||||
class="min-h-8 shrink-0 rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-semibold whitespace-nowrap text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-pressed={showModelNames}
|
||||
onclick={onToggleModelNames}
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
|
||||
{hourlyInterval === 1 ? 'left-5.5' : 'left-0.75'}"
|
||||
></span>
|
||||
{showModelNames ? m.compare_hide_model_names() : m.compare_show_model_names()}
|
||||
</button>
|
||||
<span class="select-none text-muted-foreground">1h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:px-4">
|
||||
<div
|
||||
class="overflow-x-auto rounded-lg border border-border bg-card shadow-sm"
|
||||
style="scrollbar-width: thin"
|
||||
bind:this={containerEl}
|
||||
class="relative w-full select-none overflow-hidden"
|
||||
style:touch-action="pan-y"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
aria-label={m.compare_timeline_scroll_aria()}
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointercancel={handlePointerUp}
|
||||
onpointerleave={handlePointerLeave}
|
||||
ondblclick={handleDoubleClick}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-muted/30">
|
||||
<th
|
||||
class="sticky left-0 z-20 w-32 border-b border-r border-border bg-muted/95 p-2 text-left text-xs font-bold"
|
||||
<canvas
|
||||
bind:this={canvasEl}
|
||||
class="block w-full"
|
||||
style:height="{height}px"
|
||||
role="img"
|
||||
aria-label={m.compare_timeline_caption()}
|
||||
></canvas>
|
||||
{#if dragSelect}
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 z-10 border-x-2 border-primary/70 bg-primary/15"
|
||||
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
|
||||
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
|
||||
style:height="{height}px"
|
||||
></div>
|
||||
{/if}
|
||||
{#if hoverX !== null}
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 border-l border-dashed border-muted-foreground/70 will-change-transform"
|
||||
style:height="{height}px"
|
||||
style:transform="translateX({hoverX}px)"
|
||||
></div>
|
||||
{/if}
|
||||
{#if tooltipVisible && hoverX !== null && sharedHover !== null}
|
||||
<div
|
||||
class="pointer-events-none absolute z-20 w-max max-w-[calc(100%-1rem)] rounded-md border border-border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
|
||||
style:top="{HEADER_H + 4}px"
|
||||
style:left={tooltipFlip ? 'auto' : `${hoverX + 12}px`}
|
||||
style:right={tooltipFlip ? '0.5rem' : 'auto'}
|
||||
>
|
||||
{m.compare_model_label()}
|
||||
</th>
|
||||
{#each filteredIndices as idx, i (idx)}
|
||||
{@const ts = timestamps[idx]}
|
||||
{@const isNewDay = checkNewDay(i, ts)}
|
||||
<th
|
||||
class="min-w-11 border-b border-r border-border/50 p-2 text-center text-[10px] {isNewDay
|
||||
? 'border-l-2 border-l-primary/30'
|
||||
: ''}"
|
||||
>
|
||||
<div class="font-bold">{formatZoned(new Date(ts), timezone, 'HH')}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{isNewDay ? formatZoned(new Date(ts), timezone, 'EEE d') : ''}
|
||||
<div class="mb-1 font-semibold whitespace-nowrap">
|
||||
{formatZoned(new Date(sharedHover * 1000), timezone, 'EEE d MMM HH:mm')}
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each displayModels as model (model)}
|
||||
<tr class="group hover:bg-muted/10">
|
||||
<td
|
||||
class="sticky left-0 z-10 border-b border-r border-border bg-card p-2 text-[11px] font-semibold group-hover:bg-muted/20"
|
||||
>
|
||||
{model.replace(/_/g, ' ')}
|
||||
</td>
|
||||
{#each filteredIndices as idx, i (idx)}
|
||||
{@const ts = timestamps[idx]}
|
||||
{@const codes = hourlyFlat[`weather_code_${model}`]}
|
||||
{@const code = codes ? codes[idx] : 0}
|
||||
{@const day = allDaytimeFlags[idx]}
|
||||
{@const isNewDay = checkNewDay(i, ts)}
|
||||
<td
|
||||
class="border-b border-r border-border/30 p-1.5 text-center {isNewDay
|
||||
? 'border-l-2 border-l-primary/20'
|
||||
: ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}"
|
||||
>
|
||||
{@render weatherIcon(getWeatherIconName(code, day))}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#each tooltipRows as row (row.modelId)}
|
||||
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
|
||||
<span class="size-2 shrink-0 rounded-full" style:background-color={row.color}
|
||||
></span>
|
||||
<span class="min-w-0 break-words">{row.label}:</span>
|
||||
<span class="pl-2 text-right font-semibold whitespace-nowrap">
|
||||
{row.condition}{#if row.cloudCover !== null}
|
||||
· {m.var_cloud()} {row.cloudCover}%
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
th,
|
||||
td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
cardinalDirection,
|
||||
isWindDirection,
|
||||
modelColor,
|
||||
modelMean,
|
||||
sanitizeList,
|
||||
validDirectionValues
|
||||
} from './comparison';
|
||||
|
||||
import type { ModelSeriesData } from '$lib/services/weather';
|
||||
|
||||
const models: ModelSeriesData[] = [
|
||||
{
|
||||
modelId: 'model-a',
|
||||
resolvedModelId: 'model-a',
|
||||
variables: {
|
||||
precipitation: [1, Number.NaN, 3],
|
||||
precipitation_probability: [80, 90, 100]
|
||||
}
|
||||
},
|
||||
{
|
||||
modelId: 'model-b',
|
||||
resolvedModelId: 'model-b',
|
||||
variables: { precipitation: [3, Number.NaN, 5] }
|
||||
}
|
||||
];
|
||||
|
||||
describe('multimodel comparison helpers', () => {
|
||||
it('averages only the exact scalar variable and preserves missing values', () => {
|
||||
expect(modelMean(models, 'precipitation', 3)).toEqual([2, null, 4]);
|
||||
});
|
||||
|
||||
it('validates and deduplicates shared URL selections', () => {
|
||||
const allowed = new Set(['a', 'b']);
|
||||
expect(sanitizeList(['a', 'unknown', 'a', 'b'], allowed)).toEqual(['a', 'b']);
|
||||
expect(sanitizeList(['unknown'], allowed)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats every wind direction height as a direction series', () => {
|
||||
expect(isWindDirection('wind_direction_10m')).toBe(true);
|
||||
expect(isWindDirection('wind_direction_180m')).toBe(true);
|
||||
expect(isWindDirection('wind_speed_10m')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops direction values outside the fixed 0–360 degree domain', () => {
|
||||
expect(validDirectionValues([-1, 0, 90, 360, 361, Number.NaN])).toEqual([
|
||||
null,
|
||||
0,
|
||||
90,
|
||||
360,
|
||||
null,
|
||||
null
|
||||
]);
|
||||
expect(cardinalDirection(0)).toBe('N');
|
||||
expect(cardinalDirection(270)).toBe('W');
|
||||
});
|
||||
|
||||
it('assigns deterministic model colors', () => {
|
||||
expect(modelColor('ecmwf_ifs')).toBe(modelColor('ecmwf_ifs'));
|
||||
expect(modelColor('ecmwf_ifs')).not.toBe(modelColor('gfs_global'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { findModel, models as modelOptions } from '../../options';
|
||||
|
||||
import type { ModelSeriesData } from '$lib/services/weather';
|
||||
|
||||
const MODEL_COLORS = [
|
||||
'#0072b2',
|
||||
'#d55e00',
|
||||
'#009e73',
|
||||
'#cc79a7',
|
||||
'#e69f00',
|
||||
'#56b4e9',
|
||||
'#6f4ead',
|
||||
'#8c564b',
|
||||
'#e11d48',
|
||||
'#65a30d',
|
||||
'#0891b2',
|
||||
'#c026d3',
|
||||
'#2563eb',
|
||||
'#ea580c',
|
||||
'#059669',
|
||||
'#9333ea'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* High-contrast categorical colour. Within a comparison, selection order owns
|
||||
* the palette slot so adjacent series never receive accidentally similar hues.
|
||||
*/
|
||||
export function modelColor(modelId: string, modelOrder?: readonly string[]): string {
|
||||
const selectedIndex = modelOrder?.indexOf(modelId) ?? -1;
|
||||
if (selectedIndex >= 0) return MODEL_COLORS[selectedIndex % MODEL_COLORS.length]!;
|
||||
|
||||
const canonicalIndex = modelOptions.findIndex((model) => model.value === modelId);
|
||||
if (canonicalIndex >= 0) return MODEL_COLORS[canonicalIndex % MODEL_COLORS.length]!;
|
||||
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < modelId.length; i++) {
|
||||
hash ^= modelId.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return MODEL_COLORS[Math.abs(hash) % MODEL_COLORS.length]!;
|
||||
}
|
||||
|
||||
export function modelLabel(modelId: string): string {
|
||||
return findModel(modelId)?.label ?? modelId.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function sanitizeList(
|
||||
values: string[] | null,
|
||||
allowed: ReadonlySet<string>
|
||||
): string[] | null {
|
||||
if (!values) return null;
|
||||
const clean = [...new Set(values.filter((value) => allowed.has(value)))];
|
||||
return clean.length > 0 ? clean : null;
|
||||
}
|
||||
|
||||
export function isWindDirection(variable: string): boolean {
|
||||
return variable.startsWith('wind_direction_');
|
||||
}
|
||||
|
||||
export function validDirectionValues(values: number[] | undefined): (number | null)[] {
|
||||
return (values ?? []).map((value) =>
|
||||
Number.isFinite(value) && value >= 0 && value <= 360 ? value : null
|
||||
);
|
||||
}
|
||||
|
||||
export interface PrecipitationAgreementPoint {
|
||||
wetCount: number;
|
||||
availableCount: number;
|
||||
median: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** Per-timestamp precipitation occurrence agreement and amount spread. */
|
||||
export function precipitationAgreement(
|
||||
models: ModelSeriesData[],
|
||||
variable: string,
|
||||
timeLength: number,
|
||||
wetThreshold: number
|
||||
): (PrecipitationAgreementPoint | null)[] {
|
||||
const points: (PrecipitationAgreementPoint | null)[] = new Array(timeLength).fill(null);
|
||||
for (let index = 0; index < timeLength; index++) {
|
||||
const values: number[] = [];
|
||||
for (const model of models) {
|
||||
const value = model.variables[variable]?.[index];
|
||||
if (value === undefined || !Number.isFinite(value) || value < 0) continue;
|
||||
values.push(value);
|
||||
}
|
||||
if (values.length === 0) continue;
|
||||
values.sort((left, right) => left - right);
|
||||
const middle = Math.floor(values.length / 2);
|
||||
const median =
|
||||
values.length % 2 === 0 ? (values[middle - 1] + values[middle]) / 2 : values[middle];
|
||||
points[index] = {
|
||||
wetCount: values.filter((value) => value > wetThreshold).length,
|
||||
availableCount: values.length,
|
||||
median,
|
||||
min: values[0],
|
||||
max: values[values.length - 1]
|
||||
};
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export function cardinalDirection(value: number): string {
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
return directions[Math.round((((value % 360) + 360) % 360) / 45) % 8];
|
||||
}
|
||||
|
||||
/** Arithmetic model mean for scalar variables; missing consensus stays missing. */
|
||||
export function modelMean(
|
||||
models: ModelSeriesData[],
|
||||
variable: string,
|
||||
timeLength: number
|
||||
): (number | null)[] {
|
||||
const totals = new Array<number>(timeLength).fill(0);
|
||||
const counts = new Array<number>(timeLength).fill(0);
|
||||
for (const model of models) {
|
||||
const values = model.variables[variable] ?? [];
|
||||
for (let i = 0; i < Math.min(values.length, timeLength); i++) {
|
||||
const value = values[i];
|
||||
if (!Number.isFinite(value)) continue;
|
||||
totals[i] += value;
|
||||
counts[i]++;
|
||||
}
|
||||
}
|
||||
return totals.map((total, i) =>
|
||||
counts[i] > 0 ? Math.round((total / counts[i]) * 10) / 10 : null
|
||||
);
|
||||
}
|
||||
@@ -13,17 +13,19 @@
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type HistoricalForecastResult,
|
||||
fetchClimateNormals,
|
||||
fetchHistoricalWeather
|
||||
} from '$lib/services/weather';
|
||||
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
|
||||
import { isSupporter } from '$lib/supporter/store';
|
||||
|
||||
import { useHeroActions } from '../../hero.svelte';
|
||||
import { archiveModelGroups, defaultParameters } from '../../options';
|
||||
@@ -39,8 +41,10 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => result != null);
|
||||
// The page cross-fade waits for this before revealing the new page - and so
|
||||
// does the loading overlay, so a visitor without a key has to count as ready:
|
||||
// the locked panel is the finished page here, nothing is on its way.
|
||||
reportPageReady(() => result != null || loadError != null || !$isSupporter);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
@@ -213,13 +217,15 @@
|
||||
);
|
||||
|
||||
function switchDay(date: Date) {
|
||||
selectedDay.setTime(date.getTime());
|
||||
// see the week page: an unformattable selected day breaks every consumer
|
||||
const time = date?.getTime();
|
||||
if (Number.isFinite(time)) selectedDay.setTime(time);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- the reanalysis picker rides in the layout's location row -->
|
||||
{#snippet heroActions()}
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={archiveModel}
|
||||
groups={archiveModelGroups}
|
||||
@@ -237,7 +243,7 @@
|
||||
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
|
||||
</svelte:head>
|
||||
|
||||
<PaywallGate feature={m.page_historical_subtitle()}>
|
||||
<SupporterGate feature={m.page_historical_subtitle()}>
|
||||
<DateRangeControls
|
||||
start={startDate}
|
||||
end={endDate}
|
||||
@@ -254,7 +260,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="relative mt-4">
|
||||
{#if result && fetchedHourly && fetchedDaily}
|
||||
<HistoricalDaily
|
||||
daily={result.daily}
|
||||
@@ -278,10 +284,10 @@
|
||||
|
||||
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
|
||||
{:else}
|
||||
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
||||
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
||||
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</PaywallGate>
|
||||
</SupporterGate>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { getColor, getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||
|
||||
interface Props {
|
||||
@@ -202,7 +202,8 @@
|
||||
>{formatZoned(date, timezone, 'd MMM')}</span
|
||||
>
|
||||
|
||||
<svg class="my-0.5" width="20" height="20" aria-hidden="true">
|
||||
<svg class="my-0.5" width="20" height="20">
|
||||
<title>{getWeatherDescription(daily.weather_code[i])}</title>
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
daily.weather_code[i],
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||||
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null));
|
||||
|
||||
// Same customizable layout as the 7-day meteograms, so the two pages match.
|
||||
// Same customizable layout as the week-page meteograms, so the two pages match.
|
||||
let panels = $derived(
|
||||
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { mapTransitionCover, reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||
import { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
|
||||
|
||||
import { mapsDomainForModel } from '$lib/utils/maps-domain';
|
||||
@@ -16,6 +18,23 @@
|
||||
let hashOverride = $state<string | null>(null);
|
||||
let iframeEl = $state<HTMLIFrameElement | null>(null);
|
||||
let mapReady = $state(false);
|
||||
let frameLoaded = $state(false);
|
||||
|
||||
// The map is a cross-origin iframe: until it has loaded there is nothing here
|
||||
// but an empty panel, and a page transition that ends before then cross-fades
|
||||
// the old page into that blank. Reporting readiness holds the transition (and
|
||||
// then the loading overlay) until there is actually a map to fade into.
|
||||
// `om-maps:ready` is the good signal; the iframe's own load event is the
|
||||
// backstop, so a map that fails to boot still releases the page.
|
||||
reportPageReady(() => mapReady || frameLoaded);
|
||||
|
||||
// The map stays under an opaque cover until it is ready, so it fades up from
|
||||
// a clean panel instead of flashing the iframe's white boot document (worst
|
||||
// in dark mode). The same cover is what makes navigation transitions work at
|
||||
// all here: a cross-origin iframe is never painted into a view transition
|
||||
// snapshot, so the cover is raised again just before a departure is captured
|
||||
// (see mapTransitionCover) - both snapshots then hold real pixels.
|
||||
let revealed = $derived(mapReady || frameLoaded);
|
||||
|
||||
const postToMap = (message: Record<string, unknown>) => {
|
||||
iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN);
|
||||
@@ -83,7 +102,7 @@
|
||||
|
||||
<!-- Full-bleed map: the layout drops its padding for this route. The map
|
||||
follows our theme through the color-scheme declared on :root/.dark -->
|
||||
<div class="h-full w-full bg-background">
|
||||
<div class="relative h-full w-full bg-background">
|
||||
<!-- allow="cross-origin-isolated" delegates SharedArrayBuffer use to the
|
||||
map; it only takes effect when this site itself is served with
|
||||
COOP/COEP headers (see README, Deployment) -->
|
||||
@@ -95,7 +114,22 @@
|
||||
allowfullscreen
|
||||
allow="cross-origin-isolated"
|
||||
referrerpolicy="no-referrer"
|
||||
onload={() => (frameLoaded = true)}
|
||||
class="block h-full w-full border-0"
|
||||
class:invisible={$mapTransitionCover}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
></iframe>
|
||||
|
||||
<!-- No `in:` transition on purpose: when the cover comes back for a
|
||||
departure it has to be at full opacity by the time the snapshot is
|
||||
taken, not fading towards it. The iframe is additionally made
|
||||
invisible then (above), so the capture never has to paint cross-origin
|
||||
content at all - some engines degrade the whole transition over it. -->
|
||||
{#if !revealed || $mapTransitionCover}
|
||||
<div
|
||||
class="absolute inset-0 bg-background"
|
||||
out:fade={{ duration: 300 }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,57 @@ export interface WeatherModelGroup {
|
||||
*/
|
||||
export const modelGroups: WeatherModelGroup[] = [
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Automatic',
|
||||
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }]
|
||||
value: 'automatic_seamless',
|
||||
label: 'Automatic & seamless',
|
||||
models: [
|
||||
{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' },
|
||||
{
|
||||
value: 'icon_seamless',
|
||||
label: 'DWD ICON Seamless',
|
||||
resolution: '2-13 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{ value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' },
|
||||
{
|
||||
value: 'meteofrance_seamless',
|
||||
label: 'MF Seamless',
|
||||
resolution: '1-25 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{
|
||||
value: 'ukmo_seamless',
|
||||
label: 'UKMO Seamless',
|
||||
resolution: '2-10 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{
|
||||
value: 'knmi_seamless',
|
||||
label: 'KNMI Seamless',
|
||||
resolution: '2-3 km',
|
||||
update: 'every hour'
|
||||
},
|
||||
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
|
||||
{
|
||||
value: 'metno_seamless',
|
||||
label: 'MET Norway Seamless',
|
||||
resolution: '1 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{
|
||||
value: 'meteoswiss_icon_seamless',
|
||||
label: 'MeteoSwiss ICON Seamless',
|
||||
resolution: '1-2 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{
|
||||
value: 'chmi_aladin_seamless',
|
||||
label: 'CHMI Aladin Seamless',
|
||||
resolution: '1-2.3 km',
|
||||
update: 'every 6 h'
|
||||
},
|
||||
{ value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' },
|
||||
{ value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'ecmwf',
|
||||
@@ -49,12 +97,6 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
value: 'dwd',
|
||||
label: 'DWD Germany',
|
||||
models: [
|
||||
{
|
||||
value: 'icon_seamless',
|
||||
label: 'DWD ICON Seamless',
|
||||
resolution: '2-13 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{ value: 'icon_global', label: 'DWD ICON', resolution: '13 km', update: 'every 6 h' },
|
||||
{ value: 'icon_eu', label: 'DWD ICON EU', resolution: '7 km', update: 'every 3 h' },
|
||||
{ value: 'icon_d2', label: 'DWD ICON D2', resolution: '2 km', update: 'every 3 h' }
|
||||
@@ -64,7 +106,6 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
value: 'ncep',
|
||||
label: 'NOAA U.S.',
|
||||
models: [
|
||||
{ value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' },
|
||||
{ value: 'gfs_global', label: 'GFS Global', resolution: '13 km', update: 'every 6 h' },
|
||||
{ value: 'gfs_hrrr', label: 'GFS HRRR Conus', resolution: '3 km', update: 'every hour' },
|
||||
{
|
||||
@@ -98,12 +139,6 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
value: 'meteofrance',
|
||||
label: 'Météo-France',
|
||||
models: [
|
||||
{
|
||||
value: 'meteofrance_seamless',
|
||||
label: 'MF Seamless',
|
||||
resolution: '1-25 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{
|
||||
value: 'meteofrance_arpege_world',
|
||||
label: 'MF ARPEGE World',
|
||||
@@ -134,12 +169,6 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
value: 'ukmo',
|
||||
label: 'UK Met Office',
|
||||
models: [
|
||||
{
|
||||
value: 'ukmo_seamless',
|
||||
label: 'UKMO Seamless',
|
||||
resolution: '2-10 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{
|
||||
value: 'ukmo_global_deterministic_10km',
|
||||
label: 'UK Met Office 10km',
|
||||
@@ -158,12 +187,6 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
value: 'knmi',
|
||||
label: 'KNMI Netherlands',
|
||||
models: [
|
||||
{
|
||||
value: 'knmi_seamless',
|
||||
label: 'KNMI Seamless',
|
||||
resolution: '2-3 km',
|
||||
update: 'every hour'
|
||||
},
|
||||
{
|
||||
value: 'knmi_harmonie_arome_europe',
|
||||
label: 'KNMI Harmonie Arome Europe',
|
||||
@@ -182,7 +205,6 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
value: 'dmi',
|
||||
label: 'DMI Denmark',
|
||||
models: [
|
||||
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
|
||||
{
|
||||
value: 'dmi_harmonie_arome_europe',
|
||||
label: 'DMI Harmonie Arome Europe',
|
||||
@@ -195,12 +217,6 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
value: 'metno',
|
||||
label: 'MET Norway',
|
||||
models: [
|
||||
{
|
||||
value: 'metno_seamless',
|
||||
label: 'MET Norway Seamless',
|
||||
resolution: '1 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{ value: 'metno_nordic', label: 'MET Norway Nordic', resolution: '1 km', update: 'every 3 h' }
|
||||
]
|
||||
},
|
||||
@@ -208,12 +224,6 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
value: 'meteoswiss',
|
||||
label: 'MeteoSwiss',
|
||||
models: [
|
||||
{
|
||||
value: 'meteoswiss_icon_seamless',
|
||||
label: 'MeteoSwiss ICON Seamless',
|
||||
resolution: '1-2 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{
|
||||
value: 'meteoswiss_icon_ch1',
|
||||
label: 'MeteoSwiss ICON CH1',
|
||||
@@ -228,25 +238,10 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'kma',
|
||||
label: 'KMA Korea',
|
||||
models: [
|
||||
{
|
||||
value: 'kma_seamless',
|
||||
label: 'KMA Seamless',
|
||||
resolution: '1.5-13 km',
|
||||
update: 'every 3 h'
|
||||
},
|
||||
{ value: 'kma_ldps', label: 'KMA LDPS', resolution: '1.5 km', update: 'every 3 h' },
|
||||
{ value: 'kma_gdps', label: 'KMA GDPS 12km', resolution: '13 km', update: 'every 3 h' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'jma',
|
||||
label: 'JMA Japan',
|
||||
models: [
|
||||
{ value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' },
|
||||
{ value: 'jma_msm', label: 'JMA MSM', resolution: '5 km', update: 'every 3 h' },
|
||||
{ value: 'jma_gsm', label: 'JMA GSM', resolution: '55 km', update: 'every 6 h' }
|
||||
]
|
||||
@@ -264,14 +259,20 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'bom',
|
||||
label: 'BOM Australia',
|
||||
value: 'chmi',
|
||||
label: 'CHMI Czech Republic',
|
||||
models: [
|
||||
{
|
||||
value: 'bom_access_global',
|
||||
label: 'BOM ACCESS Global',
|
||||
resolution: '15 km',
|
||||
update: 'every 12 h'
|
||||
value: 'chmi_aladin_central_europe_2km',
|
||||
label: 'CHMI Aladin Central Europe 2km',
|
||||
resolution: '2.3 km',
|
||||
update: 'every 6 h'
|
||||
},
|
||||
{
|
||||
value: 'chmi_aladin_cz_1km',
|
||||
label: 'CHMI Aladin CZ 1km',
|
||||
resolution: '1 km',
|
||||
update: 'every 6 h'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -279,7 +280,6 @@ export const modelGroups: WeatherModelGroup[] = [
|
||||
value: 'cmc_gem',
|
||||
label: 'GEM Canada',
|
||||
models: [
|
||||
{ value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' },
|
||||
{ value: 'gem_global', label: 'GEM Global', resolution: '15 km', update: 'every 12 h' },
|
||||
{ value: 'gem_regional', label: 'GEM Regional', resolution: '10 km', update: 'every 6 h' },
|
||||
{
|
||||
@@ -433,11 +433,6 @@ export const ensembleModelGroups: WeatherModelGroup[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'bom',
|
||||
label: 'BOM Australia',
|
||||
models: [{ value: 'bom_access_global_ensemble', label: 'BOM ACCESS Global' }]
|
||||
},
|
||||
{
|
||||
value: 'ukmo',
|
||||
label: 'UK Met Office',
|
||||
@@ -503,9 +498,7 @@ const IN_DOMAIN_CITY: [string, { slug: string; label: string }][] = [
|
||||
['nbm', { slug: 'new-york', label: 'New York' }],
|
||||
['gem', { slug: 'toronto', label: 'Toronto' }],
|
||||
['jma', { slug: 'tokyo', label: 'Tokyo' }],
|
||||
['kma', { slug: 'seoul', label: 'Seoul' }],
|
||||
['cma', { slug: 'beijing', label: 'Beijing' }],
|
||||
['bom', { slug: 'sydney', label: 'Sydney' }]
|
||||
['cma', { slug: 'beijing', label: 'Beijing' }]
|
||||
];
|
||||
|
||||
/** Where this model definitely has data, or null for a global model. */
|
||||
|
||||
@@ -6,19 +6,21 @@
|
||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type SeasonalForecastResult,
|
||||
fetchClimateNormals,
|
||||
fetchSeasonalForecast
|
||||
} from '$lib/services/weather';
|
||||
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
|
||||
import { isSupporter } from '$lib/supporter/store';
|
||||
|
||||
import { useHeroActions } from '../../hero.svelte';
|
||||
import { defaultParameters, seasonalModelGroups } from '../../options';
|
||||
@@ -33,8 +35,10 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => result != null);
|
||||
// The page cross-fade waits for this before revealing the new page - and so
|
||||
// does the loading overlay, so a visitor without a key has to count as ready:
|
||||
// the locked panel is the finished page here, nothing is on its way.
|
||||
reportPageReady(() => result != null || loadError != null || !$isSupporter);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
@@ -189,16 +193,27 @@
|
||||
|
||||
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
|
||||
{#snippet heroActions()}
|
||||
<!-- Out of flow on lg+ (the hero row is `relative`), same as the week, 14-day
|
||||
and archive pages: the controls then cannot move the heading when they
|
||||
change size. -->
|
||||
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
|
||||
{#if result}
|
||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
||||
<!-- Range buttons reslice the already-fetched horizon (no refetch). While a
|
||||
forecast is on its way they stay mounted but invisible, because
|
||||
mounting them on arrival re-flowed the row and nudged the heading.
|
||||
Two exceptions, both of which would reserve a gap for something that
|
||||
is never coming: below sm the group is a full-width row of its own,
|
||||
and behind the supporter gate there is no forecast on its way at all. -->
|
||||
{#if $isSupporter}
|
||||
<div
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto {result
|
||||
? ''
|
||||
: 'hidden sm:flex sm:invisible'}"
|
||||
role="group"
|
||||
aria-label={m.seasonal_range_aria()}
|
||||
aria-hidden={!result}
|
||||
>
|
||||
{#each RANGES as range, i (range.label)}
|
||||
{@const disabled = range.days !== Infinity && range.days > horizonDays}
|
||||
{@const disabled = !result || (range.days !== Infinity && range.days > horizonDays)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||
@@ -209,6 +224,7 @@
|
||||
: ''}"
|
||||
aria-pressed={rangeIndex === i}
|
||||
{disabled}
|
||||
tabindex={result ? undefined : -1}
|
||||
onclick={() => (rangeIndex = i)}
|
||||
>
|
||||
{range.label}
|
||||
@@ -253,7 +269,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaywallGate feature={m.page_seasonal_subtitle()}>
|
||||
<SupporterGate feature={m.page_seasonal_subtitle()}>
|
||||
{#if loadError}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
@@ -262,6 +278,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- `relative` anchors the placeholder while it fades out over the real
|
||||
outlook rather than holding its own slot in the layout (skeletonOut). -->
|
||||
<div class="relative">
|
||||
{#if visible && months.length > 0}
|
||||
<SeasonalMonths {months} units={params} {normals} />
|
||||
|
||||
@@ -289,7 +308,7 @@
|
||||
</ChartToolbar>
|
||||
</div>
|
||||
{:else if !loadError}
|
||||
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
||||
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
|
||||
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#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>
|
||||
@@ -298,4 +317,5 @@
|
||||
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</PaywallGate>
|
||||
</div>
|
||||
</SupporterGate>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
computeDayNightWeatherCodes,
|
||||
getWeatherDescription,
|
||||
getWeatherIconName
|
||||
} from './weather-codes';
|
||||
|
||||
const ICON_DIR = join(process.cwd(), 'static/images/weather-icons');
|
||||
|
||||
// The codes open-meteo actually emits (WMO 4677 subset), with the family each
|
||||
// one belongs to. The icon mapping used to be built for a different code table,
|
||||
// which is how fog ended up showing hail and a hail thunderstorm a tornado -
|
||||
// mismatches nothing in the type system could catch.
|
||||
const OPEN_METEO_CODES: Record<number, { meaning: string; family: RegExp }> = {
|
||||
0: { meaning: 'Clear sky', family: /clear/ },
|
||||
1: { meaning: 'Mainly clear', family: /clear|cloudy/ },
|
||||
2: { meaning: 'Partly cloudy', family: /cloudy/ },
|
||||
3: { meaning: 'Overcast', family: /cloudy/ },
|
||||
45: { meaning: 'Fog', family: /fog/ },
|
||||
48: { meaning: 'Depositing rime fog', family: /fog/ },
|
||||
51: { meaning: 'Light drizzle', family: /sprinkle|showers/ },
|
||||
53: { meaning: 'Moderate drizzle', family: /sprinkle|rain/ },
|
||||
55: { meaning: 'Dense drizzle', family: /sprinkle|rain/ },
|
||||
56: { meaning: 'Light freezing drizzle', family: /rain-mix|sleet/ },
|
||||
57: { meaning: 'Dense freezing drizzle', family: /rain-mix|sleet/ },
|
||||
61: { meaning: 'Slight rain', family: /rain|sprinkle|showers/ },
|
||||
63: { meaning: 'Moderate rain', family: /rain/ },
|
||||
65: { meaning: 'Heavy rain', family: /rain/ },
|
||||
66: { meaning: 'Light freezing rain', family: /rain-mix|sleet/ },
|
||||
67: { meaning: 'Heavy freezing rain', family: /rain-mix|sleet/ },
|
||||
71: { meaning: 'Slight snowfall', family: /snow/ },
|
||||
73: { meaning: 'Moderate snowfall', family: /snow/ },
|
||||
75: { meaning: 'Heavy snowfall', family: /snow/ },
|
||||
77: { meaning: 'Snow grains', family: /snow/ },
|
||||
80: { meaning: 'Slight rain showers', family: /showers|rain/ },
|
||||
81: { meaning: 'Moderate rain showers', family: /showers|rain/ },
|
||||
82: { meaning: 'Violent rain showers', family: /showers|rain/ },
|
||||
85: { meaning: 'Slight snow showers', family: /snow/ },
|
||||
86: { meaning: 'Heavy snow showers', family: /snow/ },
|
||||
95: { meaning: 'Thunderstorm', family: /thunderstorm|storm-showers|lightning/ },
|
||||
96: { meaning: 'Thunderstorm with slight hail', family: /thunderstorm|storm-showers|hail/ },
|
||||
99: { meaning: 'Thunderstorm with heavy hail', family: /thunderstorm|storm-showers|hail/ }
|
||||
};
|
||||
|
||||
const codes = Object.keys(OPEN_METEO_CODES).map(Number);
|
||||
|
||||
describe('getWeatherIconName', () => {
|
||||
it.each(codes)('code %i resolves to icon files that exist', (code) => {
|
||||
for (const daytime of [true, false]) {
|
||||
const name = getWeatherIconName(code, daytime);
|
||||
expect(existsSync(join(ICON_DIR, `${name}.svg`)), `${name}.svg missing`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(codes)('code %i uses a glyph that matches its meaning', (code) => {
|
||||
const { meaning, family } = OPEN_METEO_CODES[code];
|
||||
for (const daytime of [true, false]) {
|
||||
const name = getWeatherIconName(code, daytime);
|
||||
expect(name, `${code} (${meaning}) → ${name}`).toMatch(family);
|
||||
}
|
||||
});
|
||||
|
||||
it('never shows a tornado: open-meteo has no tornado code', () => {
|
||||
const names = codes.flatMap((c) => [getWeatherIconName(c, true), getWeatherIconName(c, false)]);
|
||||
expect(names.some((n) => n.includes('tornado'))).toBe(false);
|
||||
});
|
||||
|
||||
it('distinguishes overcast from partly cloudy', () => {
|
||||
expect(getWeatherIconName(3, true)).not.toBe(getWeatherIconName(2, true));
|
||||
});
|
||||
|
||||
it('falls back to a clear glyph for codes it does not know', () => {
|
||||
expect(getWeatherIconName(12345, true)).toBe('wi-day-clear');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWeatherDescription', () => {
|
||||
it.each(codes)('code %i has a description', (code) => {
|
||||
expect(getWeatherDescription(code).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns nothing for unknown or missing codes', () => {
|
||||
expect(getWeatherDescription(12345)).toBe('');
|
||||
expect(getWeatherDescription(null)).toBe('');
|
||||
expect(getWeatherDescription(undefined)).toBe('');
|
||||
expect(getWeatherDescription(NaN)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('daily aggregation', () => {
|
||||
const DAY = 24 * 3600;
|
||||
// One synthetic day: sunrise 06:00, sunset 20:00, hourly codes from 00:00.
|
||||
const hourly = (codes: number[]) => codes.map((_, i) => i * 3600 * 1000);
|
||||
const run = (hourCodes: number[]) =>
|
||||
computeDayNightWeatherCodes(hourly(hourCodes), hourCodes, [6 * 3600], [20 * 3600]).day[0];
|
||||
|
||||
const mostlyClear = (overrides: Record<number, number>) =>
|
||||
Array.from({ length: DAY / 3600 }, (_, h) => overrides[h] ?? 0);
|
||||
|
||||
it('lets a single thundery hour lead the day, as before', () => {
|
||||
expect(run(mostlyClear({ 18: 95 }))).toBe(95);
|
||||
});
|
||||
|
||||
it('picks the plain thunderstorm when a lone 99 ties with a 95', () => {
|
||||
// the reported case: one hour of 99 next to one hour of 95 used to escalate
|
||||
// the whole day card to the most extreme code on the scale
|
||||
expect(run(mostlyClear({ 18: 99, 19: 95 }))).toBe(95);
|
||||
});
|
||||
|
||||
it('still shows 99 when it is the only thunder code', () => {
|
||||
expect(run(mostlyClear({ 18: 99 }))).toBe(99);
|
||||
});
|
||||
|
||||
it('follows frequency before severity within thunder', () => {
|
||||
expect(run(mostlyClear({ 15: 99, 16: 99, 17: 95 }))).toBe(99);
|
||||
});
|
||||
|
||||
it('keeps preferring the heavier code on a tie outside thunder', () => {
|
||||
// 65 (heavy rain) over 80 (slight showers) - the open-meteo max-code flaw
|
||||
expect(run(mostlyClear({ 12: 65, 13: 80 }))).toBe(65);
|
||||
});
|
||||
|
||||
it('does not let one foggy hour brand the day', () => {
|
||||
expect(run(mostlyClear({ 7: 45 }))).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const weatherCodes: Record<number, string> = {
|
||||
0: 'clear',
|
||||
1: 'clear',
|
||||
2: 'cloudy',
|
||||
3: 'cloudy',
|
||||
3: 'overcast',
|
||||
4: 'fog',
|
||||
5: 'fog',
|
||||
10: 'fog',
|
||||
@@ -30,26 +32,26 @@ const weatherCodes: Record<number, string> = {
|
||||
42: 'rain',
|
||||
43: 'sprinkle',
|
||||
44: 'rain',
|
||||
45: 'hail',
|
||||
45: 'fog',
|
||||
46: 'hail',
|
||||
47: 'snow',
|
||||
48: 'snow',
|
||||
48: 'fog',
|
||||
50: 'sprinkle',
|
||||
51: 'sprinkle',
|
||||
52: 'rain',
|
||||
53: 'rain',
|
||||
53: 'sprinkle',
|
||||
54: 'sprinkle',
|
||||
55: 'rain',
|
||||
56: 'rain-mix',
|
||||
57: 'sprinkle',
|
||||
57: 'rain-mix',
|
||||
58: 'rain',
|
||||
60: 'sprinkle',
|
||||
61: 'sprinkle',
|
||||
62: 'rain',
|
||||
63: 'rain',
|
||||
64: 'hail',
|
||||
65: 'hail',
|
||||
66: 'hail',
|
||||
65: 'rain',
|
||||
66: 'rain-mix',
|
||||
67: 'rain-mix',
|
||||
68: 'rain-mix',
|
||||
70: 'snow',
|
||||
@@ -61,13 +63,13 @@ const weatherCodes: Record<number, string> = {
|
||||
76: 'snow',
|
||||
77: 'snow',
|
||||
78: 'snow',
|
||||
80: 'rain',
|
||||
81: 'sprinkle',
|
||||
80: 'showers',
|
||||
81: 'showers',
|
||||
82: 'rain',
|
||||
83: 'rain',
|
||||
84: 'storm-showers',
|
||||
85: 'rain-mix',
|
||||
86: 'rain-mix',
|
||||
85: 'snow',
|
||||
86: 'snow',
|
||||
87: 'rain-mix',
|
||||
89: 'hail',
|
||||
90: 'lightning',
|
||||
@@ -77,18 +79,76 @@ const weatherCodes: Record<number, string> = {
|
||||
94: 'lightning',
|
||||
95: 'thunderstorm',
|
||||
96: 'thunderstorm',
|
||||
99: 'tornado'
|
||||
99: 'storm-showers'
|
||||
};
|
||||
|
||||
// These conditions ship only as a single neutral glyph (no day/night variant).
|
||||
const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']);
|
||||
// Conditions that ship as a single neutral glyph (no day/night variant). The
|
||||
// file is not always wi-<name>: 'overcast' uses the flat cloud, which keeps it
|
||||
// distinct from 'cloudy' (code 2), whose glyph carries a sun or moon.
|
||||
const NEUTRAL_ICONS: Record<string, string> = {
|
||||
'snowflake-cold': 'wi-snowflake-cold',
|
||||
'strong-wind': 'wi-strong-wind',
|
||||
dust: 'wi-dust',
|
||||
tornado: 'wi-tornado',
|
||||
overcast: 'wi-cloudy'
|
||||
};
|
||||
|
||||
export function hasWeatherIcon(code: unknown): code is number {
|
||||
return (
|
||||
typeof code === 'number' &&
|
||||
Number.isFinite(code) &&
|
||||
Object.prototype.hasOwnProperty.call(weatherCodes, code)
|
||||
);
|
||||
}
|
||||
|
||||
export function getWeatherIconName(code: number, daytime: boolean): string {
|
||||
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
|
||||
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`;
|
||||
const neutral = NEUTRAL_ICONS[name];
|
||||
if (neutral) return neutral;
|
||||
return `wi-${daytime ? 'day' : 'night'}-${name}`;
|
||||
}
|
||||
|
||||
// Plain-language name for each code open-meteo actually emits (WMO 4677 subset),
|
||||
// used as the hover title on the pictograms. A glyph alone is ambiguous - the
|
||||
// hail-thunderstorm swirl in particular reads as something far more dramatic
|
||||
// than "thunderstorm with heavy hail".
|
||||
const WMO_DESCRIPTIONS: Record<number, () => string> = {
|
||||
0: m.wmo_0,
|
||||
1: m.wmo_1,
|
||||
2: m.wmo_2,
|
||||
3: m.wmo_3,
|
||||
45: m.wmo_45,
|
||||
48: m.wmo_48,
|
||||
51: m.wmo_51,
|
||||
53: m.wmo_53,
|
||||
55: m.wmo_55,
|
||||
56: m.wmo_56,
|
||||
57: m.wmo_57,
|
||||
61: m.wmo_61,
|
||||
63: m.wmo_63,
|
||||
65: m.wmo_65,
|
||||
66: m.wmo_66,
|
||||
67: m.wmo_67,
|
||||
71: m.wmo_71,
|
||||
73: m.wmo_73,
|
||||
75: m.wmo_75,
|
||||
77: m.wmo_77,
|
||||
80: m.wmo_80,
|
||||
81: m.wmo_81,
|
||||
82: m.wmo_82,
|
||||
85: m.wmo_85,
|
||||
86: m.wmo_86,
|
||||
95: m.wmo_95,
|
||||
96: m.wmo_96,
|
||||
99: m.wmo_99
|
||||
};
|
||||
|
||||
/** Localized condition text for a weather code; '' for codes we have no name for. */
|
||||
export function getWeatherDescription(code: number | null | undefined): string {
|
||||
if (code == null || !Number.isFinite(code)) return '';
|
||||
return WMO_DESCRIPTIONS[code]?.() ?? '';
|
||||
}
|
||||
|
||||
// ─── Local day/night weather codes ──────────────────────────────────────────
|
||||
// Open-meteo's daily weather_code is a plain numeric max over all 24 hourly
|
||||
// codes (VariableDaily.swift: `.max(.weathercode)`) — there is no day/night
|
||||
@@ -113,8 +173,17 @@ const FOG = new Set([45, 48]);
|
||||
// counterparts when breaking frequency ties (fixes 80 "outranking" 65).
|
||||
const INTENSITY_EQUIV: Record<number, number> = { 80: 61, 81: 63, 82: 65, 77: 71, 85: 71, 86: 75 };
|
||||
|
||||
/** Most frequent code; ties go to the more intense (then higher) code. */
|
||||
function modeWithHighTiebreak(codes: number[]): number {
|
||||
/**
|
||||
* Most frequent code; ties go to the more intense (then higher) code, or with
|
||||
* `preferLower` to the least intense one.
|
||||
*
|
||||
* Thunder is the one group that ties downwards. Within it the code only
|
||||
* describes how much hail comes with the storm, and letting the worst of them
|
||||
* win a coin-flip tie is how a single hour of 99 used to brand a whole day as
|
||||
* the most extreme thing on the scale. Elsewhere the heavier code winning a tie
|
||||
* is the point (heavy rain over slight showers).
|
||||
*/
|
||||
function modeWithHighTiebreak(codes: number[], preferLower = false): number {
|
||||
const counts = new Map<number, number>();
|
||||
for (const c of codes) counts.set(c, (counts.get(c) ?? 0) + 1);
|
||||
let best = codes[0];
|
||||
@@ -122,11 +191,10 @@ function modeWithHighTiebreak(codes: number[]): number {
|
||||
for (const [code, count] of counts) {
|
||||
const intensity = INTENSITY_EQUIV[code] ?? code;
|
||||
const bestIntensity = INTENSITY_EQUIV[best] ?? best;
|
||||
if (
|
||||
count > bestCount ||
|
||||
(count === bestCount &&
|
||||
(intensity > bestIntensity || (intensity === bestIntensity && code > best)))
|
||||
) {
|
||||
const winsTie = preferLower
|
||||
? intensity < bestIntensity || (intensity === bestIntensity && code < best)
|
||||
: intensity > bestIntensity || (intensity === bestIntensity && code > best);
|
||||
if (count > bestCount || (count === bestCount && winsTie)) {
|
||||
best = code;
|
||||
bestCount = count;
|
||||
}
|
||||
@@ -144,7 +212,7 @@ function daypartCode(codes: number[]): number | null {
|
||||
// (open-meteo's "don't hide hazards" philosophy, kept per-group).
|
||||
for (const group of [THUNDER, FREEZING, SNOW, RAIN, DRIZZLE]) {
|
||||
const hits = hours.filter((c) => group.has(c));
|
||||
if (hits.length > 0) return modeWithHighTiebreak(hits);
|
||||
if (hits.length > 0) return modeWithHighTiebreak(hits, group === THUNDER);
|
||||
}
|
||||
|
||||
// Fog needs persistence (≥2h and ≥¼ of the daypart) so one misty hour at
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
import { formatZoned } from '$lib/utils/date';
|
||||
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
import { syncSearchParams } from '$lib/utils/url-state';
|
||||
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
@@ -49,7 +50,7 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => fetchedDaily != null && fetchedHourly != null);
|
||||
reportPageReady(() => (fetchedDaily != null && fetchedHourly != null) || loadError != null);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
@@ -85,10 +86,21 @@
|
||||
// arrives (no layout shift)
|
||||
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
||||
|
||||
// The hourly table is a header row plus one row per enabled variable, so the
|
||||
// placeholder below reserves exactly that and nothing under it jumps when the
|
||||
// real table arrives.
|
||||
const TABLE_ROW_PX = 57;
|
||||
// The hourly table is a header row, the time/daylight row, then one row per
|
||||
// enabled variable, so the placeholder below reserves exactly that and
|
||||
// nothing under it jumps when the real table arrives. Rows are shorter below
|
||||
// lg, where the cells sit tighter - measured, not guessed: 36px against 55px,
|
||||
// and the time row is 48px at every width.
|
||||
let compactRows = $state(false);
|
||||
onMount(() => {
|
||||
const mq = window.matchMedia('(max-width: 1023px)');
|
||||
const apply = () => (compactRows = mq.matches);
|
||||
apply();
|
||||
mq.addEventListener('change', apply);
|
||||
return () => mq.removeEventListener('change', apply);
|
||||
});
|
||||
let tableRowPx = $derived(compactRows ? 36 : 55);
|
||||
const TABLE_TIME_ROW_PX = 48;
|
||||
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
|
||||
|
||||
// Request only the hourly variables the table rows and meteograms actually
|
||||
@@ -200,8 +212,13 @@
|
||||
|
||||
// Charts intentionally keep their current range: they show the full week
|
||||
// unless the user narrows it via the range presets or Ctrl+scroll.
|
||||
// A model that answers with a broken timestamp must not be able to park an
|
||||
// unreadable date in `selectedDay`: everything downstream formats it, and a
|
||||
// date that cannot be formatted takes the page with it.
|
||||
const switchDay = (date: Date) => {
|
||||
runDayTransition(() => selectedDay.setTime(date.getTime()));
|
||||
const time = date?.getTime();
|
||||
if (!Number.isFinite(time)) return;
|
||||
runDayTransition(() => selectedDay.setTime(time));
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
@@ -256,9 +273,9 @@
|
||||
const isToday = fetchedDaily
|
||||
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
|
||||
: false;
|
||||
syncSearchParams(get(page).url, {
|
||||
syncSearchParams({
|
||||
day: isToday ? null : dayKey,
|
||||
model: model && model !== 'best_match' ? model : null
|
||||
model: unlessDefault(model, 'best_match')
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,12 +346,12 @@
|
||||
<svelte:head>
|
||||
<title>Drizz.li | {m.page_week_title()}</title>
|
||||
<link rel="canonical" href="https://drizz.li/weather/week" />
|
||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||
<meta name="description" content="Weekly weather forecast with detailed hourly data" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- the model picker rides in the layout's location row (see weather/+layout) -->
|
||||
{#snippet heroActions()}
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||
onModelChange={(model) => {
|
||||
@@ -457,6 +474,9 @@
|
||||
{locationRoute}
|
||||
/>
|
||||
|
||||
<!-- `relative` is what lets the placeholder fade out on top of the table
|
||||
instead of holding a second slot in the layout (see skeletonOut). -->
|
||||
<div class="relative">
|
||||
{#if fetchedHourly && fetchedDaily}
|
||||
<div class="day-region-table" use:daySwap={selectedDayKey}>
|
||||
<HourlyTable
|
||||
@@ -470,12 +490,10 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Mirrors the real table: same header bar and the same body height,
|
||||
so the heading doesn't pop in and nothing below moves.
|
||||
Placeholders only fade IN - a fade-out would keep them in the
|
||||
layout while the real content mounts below, and everything below
|
||||
would jump the moment they finally unmount. -->
|
||||
so the heading doesn't pop in and nothing below moves. -->
|
||||
<div
|
||||
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
|
||||
@@ -490,8 +508,13 @@
|
||||
<!-- one placeholder per row the real table will render, so the body
|
||||
reads as a loading table rather than a blank panel -->
|
||||
<div class="divide-y divide-border/50">
|
||||
<!-- the time + daylight row, which is taller than the variable rows -->
|
||||
<div class="flex items-center gap-4 px-4" style="height: {TABLE_TIME_ROW_PX}px">
|
||||
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
||||
</div>
|
||||
{#each { length: enabledTableRows } as _, i (i)}
|
||||
<div class="flex items-center gap-4 px-4" style="height: {TABLE_ROW_PX}px">
|
||||
<div class="flex items-center gap-4 px-4" style="height: {tableRowPx}px">
|
||||
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
||||
</div>
|
||||
@@ -499,19 +522,29 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
{#if fetchedHourly && fetchedDaily}
|
||||
<div class="day-region-summary" use:daySwap={selectedDayKey}>
|
||||
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- same footprint as the written forecast, so it doesn't shove the
|
||||
meteograms down when it arrives -->
|
||||
<section class="mt-6" in:fade={{ duration: 200 }}>
|
||||
<div class="h-52 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-40"></div>
|
||||
<!-- 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
|
||||
@@ -525,17 +558,31 @@
|
||||
{: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">
|
||||
<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>
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
|
||||
<!-- Each meteogram is its plot plus a title row and axis labels, so
|
||||
the reserved box needs that chrome on top of the plot height or
|
||||
everything below lands ~50px per chart too high. -->
|
||||
<ChartContainer
|
||||
loading
|
||||
chartCount={enabledChartCount || 1}
|
||||
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedDayKey}
|
||||
<NearbyCities
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
|
||||
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
height="100px"
|
||||
style="filter: url(#thin-day-icon)"
|
||||
>
|
||||
<title>{getWeatherDescription(dayCode)}</title>
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
dayCode,
|
||||
@@ -208,6 +209,7 @@
|
||||
width="42px"
|
||||
height="42px"
|
||||
>
|
||||
<title>{getWeatherDescription(nightCode)}</title>
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
nightCode,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
|
||||
import {
|
||||
getSunshineColor,
|
||||
getSunshinePercent,
|
||||
@@ -276,6 +276,7 @@
|
||||
<div class="icon-row flex w-full items-center justify-center">
|
||||
<div class="icon-wrap">
|
||||
<svg class="day-icon fill-foreground">
|
||||
<title>{getWeatherDescription(dayCode)}</title>
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
dayCode,
|
||||
@@ -285,6 +286,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<svg class="night-icon self-end fill-foreground/60">
|
||||
<title>{getWeatherDescription(nightCode)}</title>
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
nightCode,
|
||||
|
||||
@@ -96,6 +96,35 @@
|
||||
return `M 0 ${-R} A ${R} ${R} 0 0 ${outerSweep} 0 ${R} A ${rx} ${R} 0 0 ${innerSweep} 0 ${-R} Z`;
|
||||
});
|
||||
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
|
||||
|
||||
// ─── Narrative clamp (phones only) ──────────────────────────────────────────
|
||||
// The narrative runs anywhere from two to nine lines depending on the day,
|
||||
// which on a phone means the whole page below it moves as soon as the data
|
||||
// lands. Below md the text is clamped to five lines and gets a toggle, and
|
||||
// the block reserves those five lines plus the toggle row whatever the
|
||||
// length - so the card is the same height before and after the fetch, and the
|
||||
// skeleton can match it exactly. From md up nothing is clamped.
|
||||
|
||||
let expanded = $state(false);
|
||||
let textEl = $state<HTMLParagraphElement>();
|
||||
let overflows = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
sentences; // re-measure when the day (and so the text) changes
|
||||
const el = textEl;
|
||||
if (!el || expanded) return; // measuring while open would always say "fits"
|
||||
|
||||
const measure = () => {
|
||||
// only clamped below md, where the toggle is the only way to see the rest
|
||||
const clamped = window.matchMedia('(max-width: 767px)').matches;
|
||||
overflows = clamped && el.scrollHeight - el.clientHeight > 1;
|
||||
};
|
||||
measure();
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="mt-6" aria-label={m.summary_heading()}>
|
||||
@@ -115,8 +144,13 @@
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 px-4 py-3.5 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-6">
|
||||
<div class="narrative">
|
||||
{#if sentences.length > 0}
|
||||
<p class="text-[15px] leading-relaxed text-foreground">
|
||||
<p
|
||||
bind:this={textEl}
|
||||
class="text-[15px] leading-relaxed text-foreground"
|
||||
class:clamped={!expanded}
|
||||
>
|
||||
{sentences.join(' ')}
|
||||
</p>
|
||||
{:else}
|
||||
@@ -125,6 +159,21 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Always occupies its row on phones, even when the text fits: a
|
||||
toggle that appears only sometimes is itself a layout shift. -->
|
||||
<button
|
||||
type="button"
|
||||
class="toggle mt-1 cursor-pointer text-[13px] font-semibold text-primary hover:underline"
|
||||
class:invisible={!overflows && !expanded}
|
||||
aria-hidden={!overflows && !expanded}
|
||||
tabindex={!overflows && !expanded ? -1 : 0}
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
{expanded ? m.summary_read_less() : m.summary_read_more()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
|
||||
<dl
|
||||
class="grid grid-cols-2 gap-x-5 gap-y-2.5 text-sm sm:grid-cols-3 lg:w-80 lg:grid-cols-2 lg:border-l lg:border-border/60 lg:pl-6"
|
||||
@@ -218,3 +267,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Phones: exactly five lines, reserved whether the text is long or short, so
|
||||
the card's height never depends on the forecast that lands. 15px text at
|
||||
leading-relaxed (1.625) → 5 lines = 8.125em. */
|
||||
.clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 5;
|
||||
line-clamp: 5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.narrative p {
|
||||
min-height: 8.125em;
|
||||
}
|
||||
|
||||
/* md and up: no clamp, no reserved height, no toggle. */
|
||||
@media (min-width: 768px) {
|
||||
.clamped {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.narrative p {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
|
||||
import {
|
||||
type FetchedDaily,
|
||||
type FetchedHourly,
|
||||
@@ -322,6 +322,10 @@
|
||||
// positioned relative to the data columns only.
|
||||
let headerColWidth = $state(0);
|
||||
let tableWidth = $state(0);
|
||||
// Measured so the centred NOW badge can be clamped fully inside the row:
|
||||
// centred on a time near midnight it would poke past the last column and
|
||||
// hand the scroller a sliver of phantom overflow.
|
||||
let nowBadgeWidth = $state(0);
|
||||
let nowLeftPx = $derived(
|
||||
nowPercent != null && tableWidth > 0
|
||||
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
|
||||
@@ -425,8 +429,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet weatherIcon(name: string, size: number = 16)}
|
||||
{#snippet weatherIcon(name: string, size: number = 16, description: string = '')}
|
||||
<svg class="inline-block fill-foreground" width={size} height={size}>
|
||||
{#if description}<title>{description}</title>{/if}
|
||||
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
|
||||
</svg>
|
||||
{/snippet}
|
||||
@@ -607,14 +612,17 @@
|
||||
{#if nowPercent != null}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-red-500/75"
|
||||
style="left:{nowPercent}%"
|
||||
style="left:clamp(1px, {nowPercent}%, calc(100% - 1px))"
|
||||
></div>
|
||||
{/if}
|
||||
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
|
||||
<!-- "Now" label, aligned with the sunrise/sunset labels along the
|
||||
bottom; its centre is clamped so the pill never leaves the row -->
|
||||
{#if isTodaySelected && nowPercent != null}
|
||||
<span
|
||||
bind:clientWidth={nowBadgeWidth}
|
||||
class="absolute bottom-0.5 z-15 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
|
||||
style="left:{nowPercent}%"
|
||||
style="left:clamp({nowBadgeWidth /
|
||||
2}px, {nowPercent}%, calc(100% - {nowBadgeWidth / 2}px))"
|
||||
>
|
||||
{m.table_now()}
|
||||
</span>
|
||||
@@ -678,7 +686,11 @@
|
||||
!cellData[i + 1].isDaytime}
|
||||
>
|
||||
{#if finite(wCode)}
|
||||
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
||||
{@render weatherIcon(
|
||||
getWeatherIconName(wCode, cell.isDaytime),
|
||||
iconPx,
|
||||
getWeatherDescription(wCode)
|
||||
)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
@@ -899,24 +911,31 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
|
||||
<!-- Hovered-column highlight, mirroring the meteogram crosshair.
|
||||
max-width caps the box at the wrapper's true (fractional) right
|
||||
edge: the widths here derive from rounded clientWidth bindings, so
|
||||
on the last column left+width can land a fraction of a pixel past
|
||||
the edge - enough scrollable overflow for a phantom scrollbar. -->
|
||||
{#if hoveredCol >= 0 && tableWidth > 0}
|
||||
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||
{@const colLeft = headerColWidth + hoveredCol * colWidth}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
|
||||
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
|
||||
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- "Now" column highlight. The current-time line itself is painted per
|
||||
cell (.now-cell) so it stays under the values and icons. -->
|
||||
cell (.now-cell) so it stays under the values and icons. Same
|
||||
max-width cap as the hover highlight above. -->
|
||||
{#if nowLeftPx != null}
|
||||
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
|
||||
{#if nowIdx >= 0}
|
||||
{@const colLeft = headerColWidth + nowIdx * colWidth}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5"
|
||||
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
|
||||
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
|
||||
></div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -64,9 +64,13 @@
|
||||
if (val) onModelChange(val);
|
||||
}}
|
||||
>
|
||||
<!-- Fixed width from sm up (mobile stays full-width): the trigger used to hug
|
||||
its content, so its size changed with every model name and differed
|
||||
between pages. One constant footprint, sized for the longest label in
|
||||
the catalogue; anything longer truncates. -->
|
||||
<Select.Trigger
|
||||
aria-label={m.model_selector_aria({ label })}
|
||||
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
|
||||
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:w-80 sm:gap-3 sm:py-2 sm:flex-none"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities';
|
||||
import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
|
||||
|
||||
import type { UnitPrefs } from '$lib/stores/settings';
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
>
|
||||
<svg class="shrink-0 fill-foreground/80" width="34px" height="34px">
|
||||
{#if day}
|
||||
<title>{getWeatherDescription(day.weatherCode)}</title>
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
day.weatherCode,
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
// Every per-location route, not just the week page: an unprerendered
|
||||
// path is served by the SPA fallback, which the host answers with a
|
||||
// 404 status. The page still works, but it costs a bogus 404 on every
|
||||
// hard reload (and tells crawlers the page does not exist).
|
||||
const cityRoutes = [
|
||||
'/weather/week',
|
||||
'/weather/compare',
|
||||
'/weather/14-day',
|
||||
'/weather/seasonal',
|
||||
'/weather/historical'
|
||||
];
|
||||
|
||||
try {
|
||||
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
|
||||
const raw = fs.readFileSync(citiesPath, 'utf-8');
|
||||
const cities = JSON.parse(raw);
|
||||
if (Array.isArray(cities)) {
|
||||
const cityEntries = cities.map((c) => `/en/weather/week/${c}`);
|
||||
const cityEntries = cities.flatMap((c) =>
|
||||
cityRoutes.map((route) => `/en${route}/${c}`)
|
||||
);
|
||||
// Keep the default wildcard to include other routes
|
||||
return ['*', ...localized, ...cityEntries];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user