53 Commits
Author SHA1 Message Date
Vincent van der Wal d2253d002d align 2026-08-10 09:22:58 +02:00
vincent f186c3ff9a Merge pull request 'feat: multimodel improvements' (#9) from multimodel-improvements into main
Reviewed-on: #9
2026-08-10 09:15:58 +02:00
Vincent van der Wal 3f9f1df8f0 merge main 2026-08-10 09:15:04 +02:00
Vincent van der Wal 032b652675 park 2026-08-07 10:58:30 +02:00
Vincent van der Wal 83b0e46e64 finish animations and add elevation 2026-08-06 22:19:17 +02:00
Vincent van der Wal d0f94094ca last fade attempt 2026-08-06 21:37:28 +02:00
Vincent van der Wal 810f9c2605 map fade 2026-08-06 21:25:29 +02:00
Vincent van der Wal c4a74ba91c flashing fixed 2026-08-06 21:23:59 +02:00
Vincent van der Wal c3bb6de49c home loading bug and flashes fixes 2026-08-06 21:10:15 +02:00
Vincent van der Wal 1c5ea2b52e beter crossfade 2026-08-06 19:24:22 +02:00
Vincent van der Wal f4a02a208c overlay 2026-08-06 19:11:11 +02:00
terraputix 506831cf88 mobile improvements mostly 2026-08-03 23:14:23 +02:00
terraputix 39f36819ae selection in page 2026-08-03 22:33:54 +02:00
terraputix b53eb77c75 fix: reapply hourly weather_code 2026-08-03 22:12:55 +02:00
terraputix b50eee634e avoid multimodel update loop 2026-08-03 21:57:36 +02:00
Vincent van der Wal 025623aaa6 rm unused urlparam 2026-08-03 13:26:30 +02:00
Vincent van der Wal dbfa38f6c5 read more text 2026-08-03 11:39:56 +02:00
Vincent van der Wal 9fc17e7417 fix incons 2026-08-03 11:05:34 +02:00
Vincent van der Wal f66e259e32 add seasonal 2026-08-03 09:57:33 +02:00
terraputix 8b58bc01d7 review fixes 2026-08-02 23:05:09 +02:00
terraputix edee9fe33d heading improvement 2026-08-02 22:45:52 +02:00
terraputix 5541df20e1 add aladin models 2026-08-02 22:42:38 +02:00
terraputix 77af339df4 redesign model and variable selection 2026-08-02 22:35:36 +02:00
terraputix ea9eb17d75 multimodel improvements 2026-08-02 22:24:51 +02:00
Vincent van der Wal bde815c35a now improvements 2026-08-02 09:53:39 +02:00
Vincent van der Wal ae50999c1c alignment and now 2026-08-02 09:03:35 +02:00
Vincent van der Wal 3fda55ddd2 last translations 2026-08-01 18:10:36 +02:00
Vincent van der Wal 578ee3c8ce small 2026-08-01 17:58:17 +02:00
Vincent van der Wal 01cc6225aa nearby 2026-08-01 17:58:03 +02:00
Vincent van der Wal b2c8108c8c small bug fixes 2026-08-01 17:24:00 +02:00
Vincent van der Wal 49e011c20b animation strip 2026-08-01 17:13:44 +02:00
Vincent van der Wal d7fe0bafc8 more translations 2026-08-01 16:36:04 +02:00
Vincent van der Wal ae88140f79 minor adjustments 2026-08-01 16:07:32 +02:00
Vincent van der Wal 17cf8df109 crossfade 2026-08-01 15:58:19 +02:00
Vincent van der Wal 0d5dbbc61f more fluent texts 2026-08-01 15:27:26 +02:00
Vincent van der Wal 774afa6c65 i18n 2026-08-01 15:07:28 +02:00
Vincent van der Wal 9806396476 day swap animation 2026-08-01 14:37:25 +02:00
Vincent van der Wal fe961a27ae layout shifts 2026-08-01 13:37:04 +02:00
Vincent van der Wal 792da49cac small adjustments buttons 2026-08-01 13:05:27 +02:00
Vincent van der Wal d3b223cbb5 cloud cover 2026-08-01 12:55:32 +02:00
Vincent van der Wal 5cc9a5428c move settings to one icon mobile 2026-08-01 12:39:19 +02:00
Vincent van der Wal 8ad02ea3f9 100% width 2026-08-01 11:40:58 +02:00
Vincent van der Wal a0379a1fd6 strip more seamless and margin improvements 2026-08-01 11:38:02 +02:00
Vincent van der Wal 99c1a89537 try strip desktop 2026-08-01 10:32:22 +02:00
Vincent van der Wal 2f86d86165 better cards, local weather codes 2026-08-01 10:11:06 +02:00
Vincent van der Wal 315d4e2bdc pages and imprint 2026-08-01 08:42:54 +02:00
Vincent van der Wal 6b2db975b3 more perf impr 2026-07-25 16:45:34 +02:00
Vincent van der Wal d37c95a46d perf enhancements 2026-07-25 16:30:58 +02:00
Vincent van der Wal cc1589b017 daycards strip 2026-07-25 16:14:13 +02:00
Vincent van der Wal 61d84e4450 historical weather 2026-07-25 15:24:03 +02:00
Vincent van der Wal f8d7c7699f defaults 2026-07-25 14:21:46 +02:00
Vincent van der Wal 1d7d9d3a53 scroll behaviour 2026-07-25 14:14:49 +02:00
Vincent van der Wal c7924bb0ae visual updates 2026-07-25 13:56:16 +02:00
505 changed files with 16696 additions and 1881 deletions
+23
View File
@@ -23,3 +23,26 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
AGENTS.md
# Local agent/editor config
.claude/
# paraglide compiles the message runtime into the source tree
/src/lib/paraglide
/.inlang
# 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
+10 -3
View File
@@ -1,4 +1,4 @@
# Drizzli
# Drizz.li
An open-source, high-performance weather forecast website built with SvelteKit and powered by the [Open-Meteo APIs](https://open-meteo.com/).
@@ -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;
}
}
```
+4
View File
@@ -25,6 +25,10 @@ export default defineConfig(
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
// Internal links go through `href()` / `gotoLocalized()` from $lib/i18n,
// which call resolve() and then add the locale prefix. The rule only
// recognises a literal resolve() call, so it cannot see through them.
'svelte/no-navigation-without-resolve': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
+422
View File
@@ -0,0 +1,422 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Wochenvorhersage",
"nav_compare": "Modellvergleich",
"nav_14day": "14-Tage-Vorhersage",
"nav_seasonal": "Saisonal",
"nav_historical": "Rückblick",
"nav_maps": "Karten",
"nav_collapse": "Einklappen",
"nav_expand_sidebar": "Seitenleiste ausklappen",
"nav_collapse_sidebar": "Seitenleiste einklappen",
"nav_home": "Drizzli Startseite",
"nav_toggle_menu": "Menü umschalten",
"search_placeholder": "Ort suchen …",
"settings_title": "Einstellungen",
"units_title": "Einheiten",
"units_aria": "Maßeinheiten wählen",
"unit_temperature": "Temperatur",
"unit_wind_speed": "Windgeschwindigkeit",
"unit_precipitation": "Niederschlag",
"theme_label": "Design",
"theme_system": "System",
"theme_light": "Hell",
"theme_dark": "Dunkel",
"theme_follow_system": "Design: System folgen",
"theme_light_title": "Design: hell",
"theme_dark_title": "Design: dunkel",
"language_label": "Sprache",
"supporter_active": "Unterstützer-Extras aktiv",
"supporter_support": "Drizz.li unterstützen",
"supporter_manage_key": "Zugangsschlüssel verwalten",
"supporter_unlock": "Unterstützer-Extras freischalten",
"supporter_badge": "Unterstützer",
"strip_past_label": "zurück",
"strip_past_aria": "Die letzten 3 Tage laden",
"strip_days_label": "Tage",
"strip_extend_aria": "Die vollen 15 Tage anzeigen",
"strip_history_label": "Archiv",
"strip_history_aria": "Wetterarchiv öffnen",
"strip_history_title": "Rückblick: jedes Datum zurück bis 1940",
"strip_seasonal_label": "saisonal",
"strip_seasonal_aria": "Saisonale Aussichten öffnen",
"strip_seasonal_title": "Saisonale Aussichten: Monatstrends für die kommenden Monate",
"day_today": "Heute",
"day_tomorrow": "Morgen",
"day_yesterday": "Gestern",
"page_week_subtitle": "Wochenvorhersage",
"page_compare_subtitle": "Modellvergleich",
"page_14day_subtitle": "14-Tage-Ensemblevorhersage",
"page_seasonal_subtitle": "Saisonale Aussichten",
"page_historical_subtitle": "Wetterrückblick",
"hourly_heading": "stündlich",
"hourly_variables": "Variablen",
"meteograms_heading": "Meteogramme",
"meteograms_customize": "Anpassen",
"meteograms_zoom_hint": "Ziehen oder",
"meteograms_zoom_hint_end": "+ Scrollen zum Zoomen",
"range_today": "Heute",
"range_selected_day": "Gewählter Tag",
"range_3_days": "3 Tage",
"range_5_days": "5 Tage",
"range_all": "Alles",
"range_group_aria": "Zeitraum des Diagramms",
"reset_zoom": "Zoom zurücksetzen",
"default_range_title": "Standard-Zeitraum",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 Tage auf dem Handy, alles auf größeren Bildschirmen",
"summary_heading": "in Worten",
"summary_no_data": "Für diesen Tag liegen keine Stundenwerte vor.",
"label_sunrise": "Sonnenaufgang",
"label_sunset": "Sonnenuntergang",
"label_daylight": "Tageslicht",
"label_uv_index": "UV-Index",
"label_moonrise": "Mondaufgang",
"label_moonset": "Monduntergang",
"label_moon": "Mond",
"daylight_hours": "{hours} Std. {minutes} Min.",
"sunshine_share": "{percent}% Sonne",
"uv_low": "Niedrig",
"uv_moderate": "Mäßig",
"uv_high": "Hoch",
"uv_very_high": "Sehr hoch",
"uv_extreme": "Extrem",
"moon_new": "Neumond",
"moon_waxing_crescent": "Zunehmende Sichel",
"moon_first_quarter": "Erstes Viertel",
"moon_waxing_gibbous": "Zunehmender Mond",
"moon_full": "Vollmond",
"moon_waning_gibbous": "Abnehmender Mond",
"moon_last_quarter": "Letztes Viertel",
"moon_waning_crescent": "Abnehmende Sichel",
"cond_clear": "klar",
"cond_fair": "teils bewölkt",
"cond_cloudy": "bedeckt",
"cond_fog": "neblig",
"cond_drizzle": "nieselig",
"cond_rain": "regnerisch",
"cond_snow": "schneereich",
"cond_thunder": "stürmisch",
"period_overnight": "nachts",
"period_morning": "morgens",
"period_afternoon": "nachmittags",
"period_evening": "in den Abendstunden",
"footer_tagline": "Schnelle, gratis, unkomplizierte Wettervorhersagen auf Basis offener Daten.",
"footer_data_by": "Wetterdaten von",
"footer_forecasts": "Vorhersagen",
"footer_popular": "Beliebte Orte",
"footer_about_section": "Über",
"model_weather": "Wettermodell",
"model_ensemble": "Ensemblemodell",
"sky_all_1": "Den ganzen Tag {condition}.",
"sky_all_2": "Ein durchweg {condition} Tag.",
"sky_all_3": "Es bleibt durchgehend {condition}.",
"sky_two_1": "{p1} {c1}, {p2} dann {c2}.",
"sky_two_2": "Der Tag beginnt {p1} {c1}, bevor es {p2} {c2} wird.",
"sky_two_3": "{p1} {c1}, {p2} dann {c2}.",
"sky_three_1": "{p1} {c1}, {p2} {c2} und {p3} dann {c3}.",
"sky_three_2": "Es beginnt {p1} {c1}, wird {p2} {c2} und endet {p3} {c3}.",
"sky_three_3": "{p1} {c1}, {p2} {c2}, {p3} schließlich {c3}.",
"temp_1": "Höchstwerte um {high}, nachts Abkühlung auf {low}.",
"temp_2": "Die Temperaturen steigen auf {high} und fallen nach Einbruch der Dunkelheit auf {low}.",
"temp_3": "Zwischen {low} und {high} im Tagesverlauf.",
"temp_feels_1": "Höchstwerte um {high}, nachts {low} - gefühlt eher {feels}.",
"temp_feels_2": "Bis {high} am Thermometer, gefühlt {feels}, bevor es auf {low} zurückgeht.",
"temp_feels_3": "Es werden {high} erreicht, gefühlt sind es {feels}, später Abkühlung auf {low}.",
"precip_window_1": "Rund {amount} Niederschlag, das meiste davon {when}.",
"precip_window_2": "Zu erwarten sind etwa {amount}, konzentriert {when}.",
"precip_window_3": "Am nassesten wird es {when}, insgesamt rund {amount}.",
"precip_spread_1": "Rund {amount} Niederschlag über den Tag verteilt.",
"precip_spread_2": "Etwa {amount} fallen in Schüben über den Tag.",
"precip_spread_3": "Immer wieder Schauer, zusammen etwa {amount}.",
"precip_chance_1": "Überwiegend trocken, mit bis zu {percent}% Schauerwahrscheinlichkeit.",
"precip_chance_2": "Kaum Regen zu erwarten, die Schauerwahrscheinlichkeit liegt bei {percent}%.",
"precip_chance_3": "{percent}% Schauerrisiko, aber kaum der Rede wert.",
"precip_dry_1": "Es bleibt durchweg trocken.",
"precip_dry_2": "Kein Tropfen in Sicht.",
"precip_dry_3": "Von früh bis spät trocken.",
"wind_dir_1": "Wind aus {direction} mit bis zu {speed}.",
"wind_dir_2": "Eine Brise aus {direction} erreicht in der Spitze {speed}.",
"wind_dir_3": "Der Wind kommt aus {direction} und erreicht Spitzen um {speed}.",
"wind_dir_gusts_1": "Wind aus {direction} mit bis zu {speed}, in Böen {gust}.",
"wind_dir_gusts_2": "Ein {direction}-Wind von {speed}, in Böen bis {gust}.",
"wind_dir_gusts_3": "Aus {direction} sind {speed} zu erwarten, zeitweise Böen von {gust}.",
"wind_1": "Wind mit bis zu {speed}.",
"wind_2": "Die Brise erreicht rund {speed}.",
"wind_3": "Luftbewegung von bis zu {speed}.",
"wind_gusts_1": "Wind mit bis zu {speed}, in Böen {gust}.",
"wind_gusts_2": "Bis zu {speed}, mit Böen von {gust}.",
"wind_gusts_3": "Ein böiger Tag: {speed} im Mittel, {gust} in der Spitze.",
"calm_1": "Kaum ein Lüftchen.",
"calm_2": "Die Luft bleibt nahezu still.",
"calm_3": "Den ganzen Tag so gut wie windstill.",
"uv_1": "UV-Index bis {value} ({label}) - mittags an Sonnenschutz denken.",
"uv_2": "Mittags brennt die Sonne: UV {value}, {label}.",
"uv_3": "Mittags besser in den Schatten - der UV-Index erreicht {value} ({label}).",
"legal_about": "Über uns",
"legal_imprint": "Impressum",
"legal_privacy": "Datenschutz",
"legal_terms": "Bedingungen",
"legal_nav": "Rechtliches",
"city_weather": "Wetter in {city}",
"var_temperature": "Temperatur",
"var_temperature_short": "Temp",
"var_icons": "Wettersymbole",
"var_icons_short": "Symbole",
"var_apparent": "Gefühlte Temp.",
"var_apparent_short": "Gefühlt",
"var_dew_point": "Taupunkt",
"var_dew_point_short": "Tau",
"var_cloud": "Bewölkung",
"var_cloud_short": "Wolken",
"var_cloud_low": "Bewölkung tief",
"var_cloud_low_short": "Tief",
"var_cloud_mid": "Bewölkung mittel",
"var_cloud_mid_short": "Mittel",
"var_cloud_high": "Bewölkung hoch",
"var_cloud_high_short": "Hoch",
"var_precipitation": "Niederschlag",
"var_precipitation_short": "Nied.",
"var_pop": "Niederschlagswahrsch.",
"var_pop_short": "Wahrsch.",
"var_rain": "Regen",
"var_rain_short": "Regen",
"var_showers": "Schauer",
"var_showers_short": "Schauer",
"var_snowfall": "Schneefall",
"var_snowfall_short": "Schnee",
"var_wind": "Windgeschwindigkeit",
"var_wind_short": "Wind",
"var_wind_dir": "Windrichtung",
"var_wind_dir_short": "Richt.",
"var_gusts": "Windböen",
"var_gusts_short": "Böen",
"var_humidity": "Luftfeuchte",
"var_humidity_short": "RF",
"var_pressure": "Luftdruck (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Bodendruck",
"var_surface_pressure_short": "Psfc",
"var_uv": "UV-Index",
"var_uv_short": "UV",
"var_visibility": "Sichtweite",
"var_visibility_short": "Sicht",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Zeit",
"no_data_title": "Für dieses Modell gibt es hier keine Daten",
"no_data_body": "Das gewählte Wettermodell deckt {location} nicht ab - regionale Modelle liefern nur Daten in ihrem eigenen Gebiet.",
"no_data_try_city": "Ort auf {city} ändern",
"no_data_best_match": "Zu „Best match“ wechseln",
"model_archive": "Reanalyse",
"model_seasonal": "Saisonmodell",
"nearby_cities_title": "Städte in der Nähe",
"nearby_cities_subtitle": "Höchst- und Tiefstwerte für den gewählten Tag",
"supporter_dialog_title": "Drizz.li Unterstützer",
"supporter_dialog_desc": "Fügen Sie den Zugangsschlüssel aus Ihrer Unterstützer-E-Mail ein, um die Extras freizuschalten.",
"supporter_extras_active": "Unterstützer-Extras sind aktiv",
"supporter_lifetime": "Lebenslanger Zugang",
"supporter_active_until": "Aktiv bis {date}",
"supporter_access_key": "Zugangsschlüssel",
"supporter_key_invalid": "Dieser Schlüssel ist ungültig oder abgelaufen.",
"supporter_server_unreachable": "Der Server ist nicht erreichbar. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"supporter_verifying": "Wird geprüft…",
"supporter_unlock_button": "Freischalten",
"supporter_remove_key": "Schlüssel von diesem Gerät entfernen",
"supporter_no_key_yet": "Noch keinen Schlüssel?",
"supporter_support_from": "Das Projekt ab {price} unterstützen",
"supporter_checking": "Ihr Zugang wird geprüft…",
"supporter_gate_title": "{feature} ist ein Unterstützer-Extra",
"supporter_gate_body": "Drizz.li ist kostenlos und quelloffen. Mit einem Beitrag ab {price} halten Sie es am Laufen - als Dankeschön schalten Unterstützer die Extras frei.",
"supporter_become": "Unterstützer werden",
"supporter_have_key": "Ich habe einen Schlüssel",
"supporter_key_expired": "Ihr gespeicherter Schlüssel ist ungültig oder abgelaufen.",
"supporter_enter_key": "Geben Sie Ihren Zugangsschlüssel ein.",
"supporter_perk_historical": "Historisches Wetter und Vergleich mit Klimanormalen",
"supporter_perk_seasonal": "Saisonale Aussichten: kommende Monate gegen die Klimanormale",
"supporter_perk_future": "Neue Unterstützer-Extras, sobald sie erscheinen",
"action_close": "Schliessen",
"action_done": "Fertig",
"action_try_again": "Erneut versuchen",
"action_reset_defaults": "Auf Standard zurücksetzen",
"customize_meteograms": "Meteogramme anpassen",
"customizer_intro": "Ziehen Sie Variablen zwischen den Diagrammen, um Ihr eigenes Layout zu bauen.",
"customizer_chart_n": "Diagramm {number}",
"customizer_delete_chart": "Diagramm {number} löschen",
"customizer_add_chart": "+ Diagramm hinzufügen",
"customizer_available": "Verfügbare Variablen",
"customizer_drag": "{variable} ziehen",
"customizer_remove": "{variable} entfernen",
"customizer_drop_here": "Variablen hierher ziehen",
"customizer_all_in_use": "Alle Variablen sind in Verwendung",
"variables_aria": "Variablenauswahl",
"variables_close": "Variablenauswahl schliessen",
"variables_table_section": "Stundentabelle",
"variables_move_up": "{variable} nach oben verschieben",
"variables_move_down": "{variable} nach unten verschieben",
"variables_charts_hint_before": "Meteogramm-Variablen konfigurieren Sie über die Schaltfläche",
"variables_charts_hint_after": "über den Diagrammen.",
"table_customize": "Variablen anpassen",
"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",
"chart_toggle_series": "{series} ein- oder ausblenden",
"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 Wochenvorhersage-Seite hinzu.",
"search_searching": "Wird gesucht…",
"search_favorites": "Favoriten",
"search_recent": "Zuletzt",
"search_hint": "Tippen Sie los oder nutzen Sie GPS, um Ihren Standort zu bestimmen",
"search_no_results": "Keine Orte gefunden",
"search_remove_recent": "{location} aus den letzten Orten entfernen",
"search_remove_recent_short": "Aus den letzten entfernen",
"search_aria": "Ort suchen",
"search_gps": "GPS-Standort verwenden",
"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",
"seasonal_explainer_after": "- nicht das Wetter an einem einzelnen Tag. Lesen Sie den Monatstrend und die Übereinstimmung des Ensembles, nicht die täglichen Ausschläge.",
"seasonal_runs_to": "Diese Aussicht reicht bis {date}.",
"seasonal_range_aria": "Zeitraum der Aussichten",
"seasonal_no_normal": "keine Normale für diesen Monat",
"normals_loading": "Normale wird geladen…",
"normals_loading_period": "Normale 19912020 wird geladen…",
"normal_band_legend": "Normalband = Mittel 19912020",
"anomaly_vs_normal": "{value} gegenüber Normal",
"stat_avg_temperature": "Durchschnittstemperatur",
"stat_mean_temperature": "Mitteltemperatur",
"stat_total_precipitation": "Niederschlagssumme",
"stat_warmest_day": "Wärmster Tag",
"stat_coldest_day": "Kältester Tag",
"historical_daily_heading": "Täglich",
"historical_daily_hint": " Tag wählen für Stundenwerte",
"historical_full_range": " gesamter Zeitraum",
"historical_from": "Von",
"historical_to": "Bis",
"historical_quick_ranges": "Schnellauswahl",
"historical_last_days": "{days} Tage",
"historical_month_last_year": "Monat, Vorjahr",
"label_day": "Tag",
"label_night": "Nacht",
"daycards_past": "Letzte 3 Tage",
"daycards_load_15": "15 Tage laden",
"error_technical_details": "Technische Details",
"err_network_title": "Der Wetterdienst ist nicht erreichbar",
"err_network_hint": "Prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.",
"err_nodata_title": "Keine Daten für diesen Ort mit dem gewählten Modell",
"err_nodata_hint": "Regionale Wettermodelle decken nur ihr eigenes Gebiet ab - \"Best match\" wählt automatisch ein passendes Modell.",
"err_rejected_title": "Der Wetterdienst hat die Anfrage abgelehnt",
"err_rejected_hint": "Versuchen Sie andere Einstellungen oder stellen Sie das Modell auf \"Best match\" zurück.",
"err_generic_title": "Die Wetterdaten konnten nicht geladen werden",
"err_generic_hint": "Versuchen Sie es gleich noch einmal. Wenn es weiterhin auftritt, wechseln Sie zum Modell \"Best match\".",
"maps_iframe_title": "Interaktive Karte von Open-Meteo",
"page_maps_title": "Wetterkarte",
"page_week_title": "Wetter",
"search_favorite_add": "Zu Favoriten hinzufügen",
"search_favorite_remove": "Aus Favoriten entfernen",
"supporter_price_per_month": "{amount} / Monat",
"model_best_match_hint": "Wählt automatisch das beste Modell für diesen Ort",
"model_updated": "aktualisiert {cadence}",
"cadence_every_hour": "stündlich",
"cadence_every_hours": "alle {hours} h",
"cadence_daily": "täglich",
"cadence_monthly": "monatlich",
"cadence_varies": "variiert",
"model_group_automatic": "Automatisch",
"model_group_reanalysis": "ECMWF-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"
}
+422
View File
@@ -0,0 +1,422 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Weekly Forecast",
"nav_compare": "Model Comparison",
"nav_14day": "14-Day Forecast",
"nav_seasonal": "Seasonal",
"nav_historical": "Historical",
"nav_maps": "Maps",
"nav_collapse": "Collapse",
"nav_expand_sidebar": "Expand sidebar",
"nav_collapse_sidebar": "Collapse sidebar",
"nav_home": "Drizzli home",
"nav_toggle_menu": "Toggle menu",
"search_placeholder": "Search location...",
"settings_title": "Settings",
"units_title": "Units",
"units_aria": "Choose measurement units",
"unit_temperature": "Temperature",
"unit_wind_speed": "Wind speed",
"unit_precipitation": "Precipitation",
"theme_label": "Theme",
"theme_system": "System",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_follow_system": "Theme: follow system",
"theme_light_title": "Theme: light",
"theme_dark_title": "Theme: dark",
"language_label": "Language",
"supporter_active": "Supporter extras active",
"supporter_support": "Support Drizz.li",
"supporter_manage_key": "Manage your access key",
"supporter_unlock": "Unlock the supporter extras",
"supporter_badge": "Supporter",
"strip_past_label": "past",
"strip_past_aria": "Load the past 3 days",
"strip_days_label": "days",
"strip_extend_aria": "Show the full 15-day forecast",
"strip_history_label": "history",
"strip_history_aria": "Open the historical weather archive",
"strip_history_title": "Historical weather: any past date back to 1940",
"strip_seasonal_label": "seasonal",
"strip_seasonal_aria": "Open the seasonal outlook",
"strip_seasonal_title": "Seasonal outlook: monthly trends for the months ahead",
"day_today": "Today",
"day_tomorrow": "Tomorrow",
"day_yesterday": "Yesterday",
"page_week_subtitle": "Weekly forecast",
"page_compare_subtitle": "Model comparison",
"page_14day_subtitle": "14-day ensemble forecast",
"page_seasonal_subtitle": "Seasonal outlook",
"page_historical_subtitle": "Historical weather",
"hourly_heading": "hourly",
"hourly_variables": "Variables",
"meteograms_heading": "Meteograms",
"meteograms_customize": "Customize",
"meteograms_zoom_hint": "drag or",
"meteograms_zoom_hint_end": "+ scroll to zoom",
"range_today": "Today",
"range_selected_day": "Selected day",
"range_3_days": "3 days",
"range_5_days": "5 days",
"range_all": "All",
"range_group_aria": "Chart time range",
"reset_zoom": "Reset zoom",
"default_range_title": "Default time range",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 days on phones, everything on wider screens",
"summary_heading": "in words",
"summary_no_data": "No hourly detail available for this day.",
"label_sunrise": "Sunrise",
"label_sunset": "Sunset",
"label_daylight": "Daylight",
"label_uv_index": "UV index",
"label_moonrise": "Moonrise",
"label_moonset": "Moonset",
"label_moon": "Moon",
"daylight_hours": "{hours} h {minutes} m",
"sunshine_share": "{percent}% sun",
"uv_low": "Low",
"uv_moderate": "Moderate",
"uv_high": "High",
"uv_very_high": "Very high",
"uv_extreme": "Extreme",
"moon_new": "New moon",
"moon_waxing_crescent": "Waxing crescent",
"moon_first_quarter": "First quarter",
"moon_waxing_gibbous": "Waxing gibbous",
"moon_full": "Full moon",
"moon_waning_gibbous": "Waning gibbous",
"moon_last_quarter": "Last quarter",
"moon_waning_crescent": "Waning crescent",
"cond_clear": "clear",
"cond_fair": "partly cloudy",
"cond_cloudy": "overcast",
"cond_fog": "foggy",
"cond_drizzle": "drizzly",
"cond_rain": "wet",
"cond_snow": "snowy",
"cond_thunder": "stormy",
"period_overnight": "overnight",
"period_morning": "in the morning",
"period_afternoon": "in the afternoon",
"period_evening": "in the evening",
"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",
"footer_about_section": "About",
"model_weather": "Weather model",
"model_ensemble": "Ensemble model",
"sky_all_1": "{condition} all day.",
"sky_all_2": "A {condition} day from start to finish.",
"sky_all_3": "It stays {condition} throughout.",
"sky_two_1": "{c1} {p1}, turning {c2} {p2}.",
"sky_two_2": "The day opens {c1} {p1} before turning {c2} {p2}.",
"sky_two_3": "Expect {c1} skies {p1}, then {c2} {p2}.",
"sky_three_1": "{c1} {p1}, turning {c2} {p2}, then {c3} {p3}.",
"sky_three_2": "It starts {c1} {p1}, becomes {c2} {p2} and ends up {c3} {p3}.",
"sky_three_3": "From {c1} skies {p1} to {c2} {p2}, before settling {c3} {p3}.",
"temp_1": "Highs near {high}, dropping to {low} overnight.",
"temp_2": "Temperatures climb to {high} and fall back to {low} after dark.",
"temp_3": "Between {low} and {high} through the day.",
"temp_feels_1": "Highs near {high}, down to {low} overnight, though it feels closer to {feels}.",
"temp_feels_2": "Up to {high} on the thermometer - closer to {feels} in the air - before easing to {low}.",
"temp_feels_3": "It reaches {high} but feels more like {feels}, cooling to {low} later.",
"precip_window_1": "Around {amount} of precipitation, most of it {when}.",
"precip_window_2": "Expect about {amount}, concentrated {when}.",
"precip_window_3": "The wettest stretch is {when}, adding up to roughly {amount}.",
"precip_spread_1": "Around {amount} of precipitation spread across the day.",
"precip_spread_2": "Roughly {amount} falls in bursts through the day.",
"precip_spread_3": "Showers come and go, totalling about {amount}.",
"precip_chance_1": "Mostly dry, with up to a {percent}% chance of catching a shower.",
"precip_chance_2": "Little rain expected, though there is a {percent}% chance of a passing shower.",
"precip_chance_3": "A {percent}% shower risk, but nothing worth an umbrella.",
"precip_dry_1": "Staying dry throughout.",
"precip_dry_2": "Not a drop expected.",
"precip_dry_3": "Dry from start to finish.",
"wind_dir_1": "Wind from the {direction} reaching {speed}.",
"wind_dir_2": "A breeze out of the {direction} tops out at {speed}.",
"wind_dir_3": "The wind sits in the {direction}, peaking near {speed}.",
"wind_dir_gusts_1": "Wind from the {direction} reaching {speed}, with gusts to {gust}.",
"wind_dir_gusts_2": "Wind out of the {direction} at {speed}, gusting as high as {gust}.",
"wind_dir_gusts_3": "Expect {speed} from the {direction}, gusting {gust} at times.",
"wind_1": "Wind reaching {speed}.",
"wind_2": "The breeze peaks around {speed}.",
"wind_3": "Air moving at up to {speed}.",
"wind_gusts_1": "Wind reaching {speed}, with gusts to {gust}.",
"wind_gusts_2": "Up to {speed}, with gusts punching through at {gust}.",
"wind_gusts_3": "A gusty day: {speed} sustained, {gust} at the peaks.",
"calm_1": "Barely a breath of wind.",
"calm_2": "The air stays almost still.",
"calm_3": "Next to no wind all day.",
"uv_1": "UV peaks at {value} ({label}), so cover up around midday.",
"uv_2": "The sun bites around noon: UV {value}, {label}.",
"uv_3": "Worth some shade in the middle of the day - UV reaches {value} ({label}).",
"legal_about": "About",
"legal_imprint": "Imprint",
"legal_privacy": "Privacy",
"legal_terms": "Terms",
"legal_nav": "Legal",
"city_weather": "{city} weather",
"var_temperature": "Temperature",
"var_temperature_short": "Temp",
"var_icons": "Weather icons",
"var_icons_short": "Icons",
"var_apparent": "Apparent Temp",
"var_apparent_short": "Feels",
"var_dew_point": "Dew Point",
"var_dew_point_short": "Dew",
"var_cloud": "Cloud Cover",
"var_cloud_short": "Cloud",
"var_cloud_low": "Cloud Cover Low",
"var_cloud_low_short": "Low",
"var_cloud_mid": "Cloud Cover Mid",
"var_cloud_mid_short": "Mid",
"var_cloud_high": "Cloud Cover High",
"var_cloud_high_short": "High",
"var_precipitation": "Precipitation",
"var_precipitation_short": "Precip",
"var_pop": "Precip. Probability",
"var_pop_short": "PoP",
"var_rain": "Rain",
"var_rain_short": "Rain",
"var_showers": "Showers",
"var_showers_short": "Shwr",
"var_snowfall": "Snowfall",
"var_snowfall_short": "Snow",
"var_wind": "Wind Speed",
"var_wind_short": "Wind",
"var_wind_dir": "Wind direction",
"var_wind_dir_short": "Dir",
"var_gusts": "Wind Gusts",
"var_gusts_short": "Gusts",
"var_humidity": "Humidity",
"var_humidity_short": "RH",
"var_pressure": "Pressure (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Surface Pressure",
"var_surface_pressure_short": "Psfc",
"var_uv": "UV Index",
"var_uv_short": "UV",
"var_visibility": "Visibility",
"var_visibility_short": "Vis",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Time",
"no_data_title": "No forecast data for this model here",
"no_data_body": "The selected weather model doesn't cover {location} - regional models only provide data inside their own area.",
"no_data_try_city": "Change location to {city}",
"no_data_best_match": "Switch to Best match",
"model_archive": "Reanalysis",
"model_seasonal": "Seasonal model",
"nearby_cities_title": "Nearby cities",
"nearby_cities_subtitle": "Highs and lows for the selected day",
"supporter_dialog_title": "Drizz.li Supporter",
"supporter_dialog_desc": "Paste the access key from your supporter email to unlock the extras.",
"supporter_extras_active": "Supporter extras are active",
"supporter_lifetime": "Lifetime access",
"supporter_active_until": "Active until {date}",
"supporter_access_key": "Access key",
"supporter_key_invalid": "That key is not valid or has expired.",
"supporter_server_unreachable": "Couldn't reach the server. Check your connection and try again.",
"supporter_verifying": "Verifying…",
"supporter_unlock_button": "Unlock",
"supporter_remove_key": "Remove key from this device",
"supporter_no_key_yet": "No key yet?",
"supporter_support_from": "Support the project from {price}",
"supporter_checking": "Checking your subscription…",
"supporter_gate_title": "{feature} is a supporter extra",
"supporter_gate_body": "Drizz.li is free and open source. Chip in from {price} to keep it running - as a thank-you, supporters unlock the extras.",
"supporter_become": "Become a supporter",
"supporter_have_key": "I have a key",
"supporter_key_expired": "Your saved key is no longer valid or has expired.",
"supporter_enter_key": "Enter your access key.",
"supporter_perk_historical": "Historical weather & climate-normal comparisons",
"supporter_perk_seasonal": "Seasonal outlook: months ahead vs the climate normal",
"supporter_perk_future": "New supporter extras as they land",
"action_close": "Close",
"action_done": "Done",
"action_try_again": "Try again",
"action_reset_defaults": "Reset to defaults",
"customize_meteograms": "Customize meteograms",
"customizer_intro": "Drag variables between charts to build your own layout.",
"customizer_chart_n": "Chart {number}",
"customizer_delete_chart": "Delete chart {number}",
"customizer_add_chart": "+ Add chart",
"customizer_available": "Available variables",
"customizer_drag": "Drag {variable}",
"customizer_remove": "Remove {variable}",
"customizer_drop_here": "Drop variables here",
"customizer_all_in_use": "All variables are in use",
"variables_aria": "Variable selection",
"variables_close": "Close variable selection",
"variables_table_section": "Hourly table",
"variables_move_up": "Move {variable} up",
"variables_move_down": "Move {variable} down",
"variables_charts_hint_before": "Meteogram variables are configured with the",
"variables_charts_hint_after": "button above the charts.",
"table_customize": "Customize variables",
"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",
"chart_toggle_series": "Toggle {series}",
"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 weekly forecast page.",
"search_searching": "Searching...",
"search_favorites": "Favorites",
"search_recent": "Recent",
"search_hint": "Start typing to search or use GPS to detect your position",
"search_no_results": "No locations found",
"search_remove_recent": "Remove {location} from recent locations",
"search_remove_recent_short": "Remove from recent",
"search_aria": "Search location",
"search_gps": "Use GPS location",
"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",
"seasonal_explainer_after": "- not the weather on any given day. Read the monthly trend and the ensemble agreement, not the daily wiggles.",
"seasonal_runs_to": "This outlook runs to {date}.",
"seasonal_range_aria": "Outlook range",
"seasonal_no_normal": "no normal for this month",
"normals_loading": "normal loading…",
"normals_loading_period": "19912020 normal loading…",
"normal_band_legend": "normal band = 19912020 mean",
"anomaly_vs_normal": "{value} vs normal",
"stat_avg_temperature": "Average temperature",
"stat_mean_temperature": "Mean temperature",
"stat_total_precipitation": "Total precipitation",
"stat_warmest_day": "Warmest day",
"stat_coldest_day": "Coldest day",
"historical_daily_heading": "Daily",
"historical_daily_hint": " select a day for hourly detail",
"historical_full_range": " full range",
"historical_from": "From",
"historical_to": "To",
"historical_quick_ranges": "Quick ranges",
"historical_last_days": "{days} days",
"historical_month_last_year": "Month, last year",
"label_day": "day",
"label_night": "night",
"daycards_past": "Past 3 days",
"daycards_load_15": "Load 15 days",
"error_technical_details": "Technical details",
"err_network_title": "Couldn't reach the weather service",
"err_network_hint": "Check your internet connection and try again.",
"err_nodata_title": "No data for this location with the selected model",
"err_nodata_hint": "Regional weather models only cover their own area - \"Best match\" picks a suitable model automatically.",
"err_rejected_title": "The weather service rejected the request",
"err_rejected_hint": "Try different settings, or switch the model back to \"Best match\".",
"err_generic_title": "Loading the weather data failed",
"err_generic_hint": "Try again in a moment. If it keeps happening, switch the model to \"Best match\".",
"maps_iframe_title": "Open-Meteo interactive map",
"page_maps_title": "Weather map",
"page_week_title": "Weather",
"search_favorite_add": "Add to favorites",
"search_favorite_remove": "Remove from favorites",
"supporter_price_per_month": "{amount} / month",
"model_best_match_hint": "Automatically picks the best model for this location",
"model_updated": "updated {cadence}",
"cadence_every_hour": "every hour",
"cadence_every_hours": "every {hours} h",
"cadence_daily": "daily",
"cadence_monthly": "monthly",
"cadence_varies": "varies",
"model_group_automatic": "Automatic",
"model_group_reanalysis": "ECMWF 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"
}
+422
View File
@@ -0,0 +1,422 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Pronóstico semanal",
"nav_compare": "Comparación de modelos",
"nav_14day": "Pronóstico de 14 días",
"nav_seasonal": "Estacional",
"nav_historical": "Histórico",
"nav_maps": "Mapas",
"nav_collapse": "Contraer",
"nav_expand_sidebar": "Expandir barra lateral",
"nav_collapse_sidebar": "Contraer barra lateral",
"nav_home": "Inicio de Drizzli",
"nav_toggle_menu": "Alternar menú",
"search_placeholder": "Buscar ubicación…",
"settings_title": "Ajustes",
"units_title": "Unidades",
"units_aria": "Elegir unidades de medida",
"unit_temperature": "Temperatura",
"unit_wind_speed": "Velocidad del viento",
"unit_precipitation": "Precipitación",
"theme_label": "Tema",
"theme_system": "Sistema",
"theme_light": "Claro",
"theme_dark": "Oscuro",
"theme_follow_system": "Tema: seguir al sistema",
"theme_light_title": "Tema: claro",
"theme_dark_title": "Tema: oscuro",
"language_label": "Idioma",
"supporter_active": "Extras de colaborador activos",
"supporter_support": "Apoya Drizz.li",
"supporter_manage_key": "Gestiona tu clave de acceso",
"supporter_unlock": "Desbloquea los extras de colaborador",
"supporter_badge": "Colaborador",
"strip_past_label": "antes",
"strip_past_aria": "Cargar los últimos 3 días",
"strip_days_label": "días",
"strip_extend_aria": "Ver el pronóstico completo de 15 días",
"strip_history_label": "archivo",
"strip_history_aria": "Abrir el archivo meteorológico",
"strip_history_title": "Histórico: cualquier fecha desde 1940",
"strip_seasonal_label": "estacional",
"strip_seasonal_aria": "Abrir la previsión estacional",
"strip_seasonal_title": "Previsión estacional: tendencias de los próximos meses",
"day_today": "Hoy",
"day_tomorrow": "Mañana",
"day_yesterday": "Ayer",
"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",
"meteograms_customize": "Personalizar",
"meteograms_zoom_hint": "arrastra o",
"meteograms_zoom_hint_end": "+ desplazar para ampliar",
"range_today": "Hoy",
"range_selected_day": "Día seleccionado",
"range_3_days": "3 días",
"range_5_days": "5 días",
"range_all": "Todo",
"range_group_aria": "Rango temporal del gráfico",
"reset_zoom": "Restablecer zoom",
"default_range_title": "Rango temporal predeterminado",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 días en el móvil, todo en pantallas anchas",
"summary_heading": "en palabras",
"summary_no_data": "No hay datos horarios para este día.",
"label_sunrise": "Amanecer",
"label_sunset": "Atardecer",
"label_daylight": "Luz diurna",
"label_uv_index": "Índice UV",
"label_moonrise": "Salida de la luna",
"label_moonset": "Puesta de la luna",
"label_moon": "Luna",
"daylight_hours": "{hours} h {minutes} min",
"sunshine_share": "{percent}% de sol",
"uv_low": "Bajo",
"uv_moderate": "Moderado",
"uv_high": "Alto",
"uv_very_high": "Muy alto",
"uv_extreme": "Extremo",
"moon_new": "Luna nueva",
"moon_waxing_crescent": "Luna creciente",
"moon_first_quarter": "Cuarto creciente",
"moon_waxing_gibbous": "Gibosa creciente",
"moon_full": "Luna llena",
"moon_waning_gibbous": "Gibosa menguante",
"moon_last_quarter": "Cuarto menguante",
"moon_waning_crescent": "Luna menguante",
"cond_clear": "despejado",
"cond_fair": "parcialmente nublado",
"cond_cloudy": "nublado",
"cond_fog": "con niebla",
"cond_drizzle": "con llovizna",
"cond_rain": "lluvioso",
"cond_snow": "con nieve",
"cond_thunder": "tormentoso",
"period_overnight": "por la noche",
"period_morning": "por la mañana",
"period_afternoon": "por la tarde",
"period_evening": "al anochecer",
"footer_tagline": "Pronósticos rápidos y directos basados en datos abiertos.",
"footer_data_by": "Datos meteorológicos de",
"footer_forecasts": "Pronósticos",
"footer_popular": "Ubicaciones populares",
"footer_about_section": "Acerca de",
"model_weather": "Modelo meteorológico",
"model_ensemble": "Modelo de conjunto",
"sky_all_1": "{condition} todo el día.",
"sky_all_2": "Un día {condition} de principio a fin.",
"sky_all_3": "Se mantiene {condition} durante toda la jornada.",
"sky_two_1": "{c1} {p1} y {c2} {p2}.",
"sky_two_2": "La jornada empieza {c1} {p1} antes de volverse {c2} {p2}.",
"sky_two_3": "Se espera cielo {c1} {p1} y {c2} {p2}.",
"sky_three_1": "{c1} {p1}, {c2} {p2} y {c3} {p3}.",
"sky_three_2": "Empieza {c1} {p1}, se vuelve {c2} {p2} y acaba {c3} {p3}.",
"sky_three_3": "De cielo {c1} {p1} a {c2} {p2}, para quedarse {c3} {p3}.",
"temp_1": "Máximas de {high} y mínimas de {low} por la noche.",
"temp_2": "Las temperaturas suben hasta {high} y bajan a {low} tras el anochecer.",
"temp_3": "Entre {low} y {high} a lo largo del día.",
"temp_feels_1": "Máximas de {high} y mínimas de {low}, aunque la sensación será de {feels}.",
"temp_feels_2": "Hasta {high} en el termómetro, aunque se sentirán {feels}, antes de bajar a {low}.",
"temp_feels_3": "Alcanza {high} pero se sentirá como {feels}, con descenso a {low} más tarde.",
"precip_window_1": "Unos {amount} de precipitación, sobre todo {when}.",
"precip_window_2": "Se esperan unos {amount}, concentrados {when}.",
"precip_window_3": "El tramo más húmedo será {when}, con un total aproximado de {amount}.",
"precip_spread_1": "Unos {amount} de precipitación repartidos a lo largo del día.",
"precip_spread_2": "Aproximadamente {amount} caen a intervalos durante el día.",
"precip_spread_3": "Chubascos intermitentes que suman unos {amount}.",
"precip_chance_1": "Mayormente seco, con hasta un {percent}% de probabilidad de chubascos.",
"precip_chance_2": "Apenas se espera lluvia, aunque hay un {percent}% de probabilidad de un chubasco pasajero.",
"precip_chance_3": "Un {percent}% de riesgo de chubascos, pero nada que exija paraguas.",
"precip_dry_1": "Se mantendrá seco todo el día.",
"precip_dry_2": "No se espera ni una gota.",
"precip_dry_3": "Seco de principio a fin.",
"wind_dir_1": "Viento del {direction} de hasta {speed}.",
"wind_dir_2": "La brisa del {direction} alcanza los {speed}.",
"wind_dir_3": "El viento sopla del {direction}, con picos cercanos a {speed}.",
"wind_dir_gusts_1": "Viento del {direction} de hasta {speed}, con rachas de {gust}.",
"wind_dir_gusts_2": "Un viento del {direction} de {speed} con rachas de hasta {gust}.",
"wind_dir_gusts_3": "Se esperan {speed} del {direction}, con rachas puntuales de {gust}.",
"wind_1": "Viento de hasta {speed}.",
"wind_2": "La brisa alcanza unos {speed}.",
"wind_3": "Aire en movimiento de hasta {speed}.",
"wind_gusts_1": "Viento de hasta {speed}, con rachas de {gust}.",
"wind_gusts_2": "Hasta {speed}, con rachas que llegan a {gust}.",
"wind_gusts_3": "Un día racheado: {speed} sostenidos y {gust} en los picos.",
"calm_1": "Apenas hay viento.",
"calm_2": "El aire se mantiene casi en calma.",
"calm_3": "Prácticamente sin viento en todo el día.",
"uv_1": "El índice UV llega a {value} ({label}), así que protégete al mediodía.",
"uv_2": "El sol aprieta al mediodía: UV {value}, {label}.",
"uv_3": "Conviene buscar sombra al mediodía: el UV alcanza {value} ({label}).",
"legal_about": "Acerca de",
"legal_imprint": "Aviso legal",
"legal_privacy": "Privacidad",
"legal_terms": "Condiciones",
"legal_nav": "Legal",
"city_weather": "El tiempo en {city}",
"var_temperature": "Temperatura",
"var_temperature_short": "Temp",
"var_icons": "Iconos",
"var_icons_short": "Iconos",
"var_apparent": "Sensación térmica",
"var_apparent_short": "Sensación",
"var_dew_point": "Punto de rocío",
"var_dew_point_short": "Rocío",
"var_cloud": "Nubosidad",
"var_cloud_short": "Nubes",
"var_cloud_low": "Nubosidad baja",
"var_cloud_low_short": "Baja",
"var_cloud_mid": "Nubosidad media",
"var_cloud_mid_short": "Media",
"var_cloud_high": "Nubosidad alta",
"var_cloud_high_short": "Alta",
"var_precipitation": "Precipitación",
"var_precipitation_short": "Precip.",
"var_pop": "Prob. de precipitación",
"var_pop_short": "Prob.",
"var_rain": "Lluvia",
"var_rain_short": "Lluvia",
"var_showers": "Chubascos",
"var_showers_short": "Chub.",
"var_snowfall": "Nevadas",
"var_snowfall_short": "Nieve",
"var_wind": "Velocidad del viento",
"var_wind_short": "Viento",
"var_wind_dir": "Dirección del viento",
"var_wind_dir_short": "Dir.",
"var_gusts": "Rachas de viento",
"var_gusts_short": "Rachas",
"var_humidity": "Humedad",
"var_humidity_short": "HR",
"var_pressure": "Presión (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Presión en superficie",
"var_surface_pressure_short": "Psfc",
"var_uv": "Índice UV",
"var_uv_short": "UV",
"var_visibility": "Visibilidad",
"var_visibility_short": "Vis.",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Hora",
"no_data_title": "Este modelo no tiene datos aquí",
"no_data_body": "El modelo seleccionado no cubre {location}: los modelos regionales solo ofrecen datos dentro de su propia área.",
"no_data_try_city": "Cambiar la ubicación a {city}",
"no_data_best_match": "Cambiar a «Best match»",
"model_archive": "Reanálisis",
"model_seasonal": "Modelo estacional",
"nearby_cities_title": "Ciudades cercanas",
"nearby_cities_subtitle": "Máximas y mínimas del día seleccionado",
"supporter_dialog_title": "Colaborador de Drizz.li",
"supporter_dialog_desc": "Pega la clave de acceso de tu correo de colaborador para desbloquear los extras.",
"supporter_extras_active": "Los extras de colaborador están activos",
"supporter_lifetime": "Acceso de por vida",
"supporter_active_until": "Activo hasta el {date}",
"supporter_access_key": "Clave de acceso",
"supporter_key_invalid": "Esa clave no es válida o ha caducado.",
"supporter_server_unreachable": "No se pudo conectar con el servidor. Comprueba tu conexión e inténtalo de nuevo.",
"supporter_verifying": "Verificando…",
"supporter_unlock_button": "Desbloquear",
"supporter_remove_key": "Quitar la clave de este dispositivo",
"supporter_no_key_yet": "¿Aún no tienes clave?",
"supporter_support_from": "Apoya el proyecto desde {price}",
"supporter_checking": "Comprobando tu suscripción…",
"supporter_gate_title": "{feature} es un extra para colaboradores",
"supporter_gate_body": "Drizz.li es gratuito y de código abierto. Aporta desde {price} para mantenerlo en marcha; como agradecimiento, los colaboradores desbloquean los extras.",
"supporter_become": "Hazte colaborador",
"supporter_have_key": "Ya tengo una clave",
"supporter_key_expired": "Tu clave guardada ya no es válida o ha caducado.",
"supporter_enter_key": "Introduce tu clave de acceso.",
"supporter_perk_historical": "Clima histórico y comparación con las normales climáticas",
"supporter_perk_seasonal": "Perspectiva estacional: los próximos meses frente a la normal climática",
"supporter_perk_future": "Nuevos extras para colaboradores en cuanto lleguen",
"action_close": "Cerrar",
"action_done": "Listo",
"action_try_again": "Reintentar",
"action_reset_defaults": "Restablecer valores por defecto",
"customize_meteograms": "Personalizar meteogramas",
"customizer_intro": "Arrastra variables entre gráficos para crear tu propio diseño.",
"customizer_chart_n": "Gráfico {number}",
"customizer_delete_chart": "Eliminar el gráfico {number}",
"customizer_add_chart": "+ Añadir gráfico",
"customizer_available": "Variables disponibles",
"customizer_drag": "Arrastrar {variable}",
"customizer_remove": "Quitar {variable}",
"customizer_drop_here": "Suelta variables aquí",
"customizer_all_in_use": "Todas las variables están en uso",
"variables_aria": "Selección de variables",
"variables_close": "Cerrar la selección de variables",
"variables_table_section": "Tabla horaria",
"variables_move_up": "Subir {variable}",
"variables_move_down": "Bajar {variable}",
"variables_charts_hint_before": "Las variables del meteograma se configuran con el botón",
"variables_charts_hint_after": "situado encima de los gráficos.",
"table_customize": "Personalizar variables",
"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",
"chart_toggle_series": "Mostrar u ocultar {series}",
"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 del pronóstico semanal.",
"search_searching": "Buscando…",
"search_favorites": "Favoritos",
"search_recent": "Recientes",
"search_hint": "Empieza a escribir o usa el GPS para detectar tu posición",
"search_no_results": "No se encontraron ubicaciones",
"search_remove_recent": "Quitar {location} de las ubicaciones recientes",
"search_remove_recent_short": "Quitar de recientes",
"search_aria": "Buscar ubicación",
"search_gps": "Usar la ubicación GPS",
"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",
"seasonal_explainer_after": "- no el tiempo de un día concreto. Fíjate en la tendencia mensual y en el acuerdo del ensemble, no en los vaivenes diarios.",
"seasonal_runs_to": "Esta perspectiva llega hasta el {date}.",
"seasonal_range_aria": "Periodo de la perspectiva",
"seasonal_no_normal": "sin normal para este mes",
"normals_loading": "cargando la normal…",
"normals_loading_period": "cargando la normal 19912020…",
"normal_band_legend": "banda normal = media 19912020",
"anomaly_vs_normal": "{value} respecto a la normal",
"stat_avg_temperature": "Temperatura media",
"stat_mean_temperature": "Temperatura media",
"stat_total_precipitation": "Precipitación total",
"stat_warmest_day": "Día más cálido",
"stat_coldest_day": "Día más frío",
"historical_daily_heading": "Diario",
"historical_daily_hint": " selecciona un día para el detalle horario",
"historical_full_range": " rango completo",
"historical_from": "Desde",
"historical_to": "Hasta",
"historical_quick_ranges": "Rangos rápidos",
"historical_last_days": "{days} días",
"historical_month_last_year": "Este mes, el año pasado",
"label_day": "día",
"label_night": "noche",
"daycards_past": "3 días pasados",
"daycards_load_15": "Cargar 15 días",
"error_technical_details": "Detalles técnicos",
"err_network_title": "No se pudo conectar con el servicio meteorológico",
"err_network_hint": "Comprueba tu conexión a internet e inténtalo de nuevo.",
"err_nodata_title": "No hay datos para esta ubicación con el modelo seleccionado",
"err_nodata_hint": "Los modelos regionales solo cubren su propia área - \"Best match\" elige automáticamente un modelo adecuado.",
"err_rejected_title": "El servicio meteorológico rechazó la solicitud",
"err_rejected_hint": "Prueba otros ajustes o vuelve al modelo \"Best match\".",
"err_generic_title": "No se pudieron cargar los datos meteorológicos",
"err_generic_hint": "Vuelve a intentarlo en un momento. Si sigue ocurriendo, cambia el modelo a \"Best match\".",
"maps_iframe_title": "Mapa interactivo de Open-Meteo",
"page_maps_title": "Mapa meteorológico",
"page_week_title": "El tiempo",
"search_favorite_add": "Añadir a favoritos",
"search_favorite_remove": "Quitar de favoritos",
"supporter_price_per_month": "{amount} / mes",
"model_best_match_hint": "Elige automáticamente el mejor modelo para esta ubicación",
"model_updated": "actualizado {cadence}",
"cadence_every_hour": "cada hora",
"cadence_every_hours": "cada {hours} h",
"cadence_daily": "a diario",
"cadence_monthly": "cada mes",
"cadence_varies": "variable",
"model_group_automatic": "Automático",
"model_group_reanalysis": "Reanálisis del ECMWF",
"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"
}
+422
View File
@@ -0,0 +1,422 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Prévisions hebdomadaires",
"nav_compare": "Comparaison des modèles",
"nav_14day": "Prévisions à 14 jours",
"nav_seasonal": "Saisonnier",
"nav_historical": "Historique",
"nav_maps": "Cartes",
"nav_collapse": "Réduire",
"nav_expand_sidebar": "Déployer la barre latérale",
"nav_collapse_sidebar": "Réduire la barre latérale",
"nav_home": "Accueil Drizzli",
"nav_toggle_menu": "Basculer le menu",
"search_placeholder": "Rechercher un lieu…",
"settings_title": "Paramètres",
"units_title": "Unités",
"units_aria": "Choisir les unités de mesure",
"unit_temperature": "Température",
"unit_wind_speed": "Vitesse du vent",
"unit_precipitation": "Précipitations",
"theme_label": "Thème",
"theme_system": "Système",
"theme_light": "Clair",
"theme_dark": "Sombre",
"theme_follow_system": "Thème : suivre le système",
"theme_light_title": "Thème : clair",
"theme_dark_title": "Thème : sombre",
"language_label": "Langue",
"supporter_active": "Bonus contributeur actifs",
"supporter_support": "Soutenir Drizz.li",
"supporter_manage_key": "Gérer votre clé d'accès",
"supporter_unlock": "Débloquer les bonus contributeur",
"supporter_badge": "Contributeur",
"strip_past_label": "avant",
"strip_past_aria": "Charger les 3 derniers jours",
"strip_days_label": "jours",
"strip_extend_aria": "Afficher les 15 jours complets",
"strip_history_label": "archives",
"strip_history_aria": "Ouvrir les archives météo",
"strip_history_title": "Historique : toute date depuis 1940",
"strip_seasonal_label": "saisonnier",
"strip_seasonal_aria": "Ouvrir les tendances saisonnières",
"strip_seasonal_title": "Tendances saisonnières : les mois à venir",
"day_today": "Aujourd'hui",
"day_tomorrow": "Demain",
"day_yesterday": "Hier",
"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",
"meteograms_customize": "Personnaliser",
"meteograms_zoom_hint": "glissez ou",
"meteograms_zoom_hint_end": "+ défiler pour zoomer",
"range_today": "Aujourd'hui",
"range_selected_day": "Jour sélectionné",
"range_3_days": "3 jours",
"range_5_days": "5 jours",
"range_all": "Tout",
"range_group_aria": "Plage horaire du graphique",
"reset_zoom": "Réinitialiser le zoom",
"default_range_title": "Plage horaire par défaut",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 jours sur mobile, tout sur grand écran",
"summary_heading": "en résumé",
"summary_no_data": "Aucun détail horaire pour ce jour.",
"label_sunrise": "Lever du soleil",
"label_sunset": "Coucher du soleil",
"label_daylight": "Durée du jour",
"label_uv_index": "Indice UV",
"label_moonrise": "Lever de la lune",
"label_moonset": "Coucher de la lune",
"label_moon": "Lune",
"daylight_hours": "{hours} h {minutes} min",
"sunshine_share": "{percent}% de soleil",
"uv_low": "Faible",
"uv_moderate": "Modéré",
"uv_high": "Élevé",
"uv_very_high": "Très élevé",
"uv_extreme": "Extrême",
"moon_new": "Nouvelle lune",
"moon_waxing_crescent": "Premier croissant",
"moon_first_quarter": "Premier quartier",
"moon_waxing_gibbous": "Gibbeuse croissante",
"moon_full": "Pleine lune",
"moon_waning_gibbous": "Gibbeuse décroissante",
"moon_last_quarter": "Dernier quartier",
"moon_waning_crescent": "Dernier croissant",
"cond_clear": "dégagé",
"cond_fair": "partiellement nuageux",
"cond_cloudy": "couvert",
"cond_fog": "brumeux",
"cond_drizzle": "bruineux",
"cond_rain": "pluvieux",
"cond_snow": "neigeux",
"cond_thunder": "orageux",
"period_overnight": "la nuit",
"period_morning": "le matin",
"period_afternoon": "l'après-midi",
"period_evening": "en soirée",
"footer_tagline": "Des prévisions météo rapides et sans fioritures, basées sur des données ouvertes.",
"footer_data_by": "Données météo par",
"footer_forecasts": "Prévisions",
"footer_popular": "Lieux populaires",
"footer_about_section": "À propos",
"model_weather": "Modèle météo",
"model_ensemble": "Modèle d'ensemble",
"sky_all_1": "{condition} toute la journée.",
"sky_all_2": "Une journée {condition} du matin au soir.",
"sky_all_3": "Le ciel reste {condition} tout du long.",
"sky_two_1": "{c1} {p1}, puis {c2} {p2}.",
"sky_two_2": "La journée débute {c1} {p1} avant de devenir {c2} {p2}.",
"sky_two_3": "Ciel {c1} {p1}, puis {c2} {p2}.",
"sky_three_1": "{c1} {p1}, puis {c2} {p2} et enfin {c3} {p3}.",
"sky_three_2": "Le ciel est {c1} {p1}, devient {c2} {p2} et finit {c3} {p3}.",
"sky_three_3": "D'un ciel {c1} {p1} à {c2} {p2}, avant de rester {c3} {p3}.",
"temp_1": "Maximales proches de {high}, minimales de {low} la nuit.",
"temp_2": "Les températures montent à {high} et retombent à {low} après la tombée de la nuit.",
"temp_3": "Entre {low} et {high} au fil de la journée.",
"temp_feels_1": "Maximales proches de {high}, minimales de {low}, mais ressenti plutôt {feels}.",
"temp_feels_2": "Jusqu'à {high} au thermomètre, ressenti {feels}, avant de redescendre à {low}.",
"temp_feels_3": "Il fera {high} mais on ressentira {feels}, avec {low} plus tard.",
"precip_window_1": "Environ {amount} de précipitations, surtout {when}.",
"precip_window_2": "Comptez environ {amount}, concentrés {when}.",
"precip_window_3": "Le passage le plus humide se situe {when}, pour environ {amount} au total.",
"precip_spread_1": "Environ {amount} de précipitations réparties sur la journée.",
"precip_spread_2": "Environ {amount} tombent par intermittence au cours de la journée.",
"precip_spread_3": "Des averses vont et viennent, pour un total d'environ {amount}.",
"precip_chance_1": "Temps plutôt sec, avec jusqu'à {percent}% de risque d'averse.",
"precip_chance_2": "Peu de pluie attendue, mais {percent}% de risque d'une averse passagère.",
"precip_chance_3": "{percent}% de risque d'averse, rien qui justifie un parapluie.",
"precip_dry_1": "Le temps reste sec toute la journée.",
"precip_dry_2": "Pas une goutte attendue.",
"precip_dry_3": "Sec du début à la fin.",
"wind_dir_1": "Vent de secteur {direction} atteignant {speed}.",
"wind_dir_2": "Une brise de secteur {direction} culmine à {speed}.",
"wind_dir_3": "Le vent s'oriente au secteur {direction}, avec des pointes proches de {speed}.",
"wind_dir_gusts_1": "Vent de secteur {direction} atteignant {speed}, avec des rafales à {gust}.",
"wind_dir_gusts_2": "Un vent de secteur {direction} de {speed}, avec des rafales jusqu'à {gust}.",
"wind_dir_gusts_3": "Attendez-vous à {speed} de secteur {direction}, avec des rafales de {gust} par moments.",
"wind_1": "Vent atteignant {speed}.",
"wind_2": "La brise culmine autour de {speed}.",
"wind_3": "Un vent allant jusqu'à {speed}.",
"wind_gusts_1": "Vent atteignant {speed}, avec des rafales à {gust}.",
"wind_gusts_2": "Jusqu'à {speed}, avec des rafales atteignant {gust}.",
"wind_gusts_3": "Une journée à rafales : {speed} en continu, {gust} en pointe.",
"calm_1": "Pratiquement pas de vent.",
"calm_2": "L'air reste quasiment immobile.",
"calm_3": "Quasiment aucun vent de la journée.",
"uv_1": "Indice UV jusqu'à {value} ({label}) : protégez-vous en milieu de journée.",
"uv_2": "Le soleil tape à la mi-journée : UV {value}, {label}.",
"uv_3": "Mieux vaut chercher l'ombre à midi : l'UV atteint {value} ({label}).",
"legal_about": "À propos",
"legal_imprint": "Mentions légales",
"legal_privacy": "Confidentialité",
"legal_terms": "Conditions",
"legal_nav": "Informations légales",
"city_weather": "Météo à {city}",
"var_temperature": "Température",
"var_temperature_short": "Temp",
"var_icons": "Icônes météo",
"var_icons_short": "Icônes",
"var_apparent": "Ressenti",
"var_apparent_short": "Ressenti",
"var_dew_point": "Point de rosée",
"var_dew_point_short": "Rosée",
"var_cloud": "Nébulosité",
"var_cloud_short": "Nuages",
"var_cloud_low": "Nébulosité basse",
"var_cloud_low_short": "Basse",
"var_cloud_mid": "Nébulosité moyenne",
"var_cloud_mid_short": "Moyenne",
"var_cloud_high": "Nébulosité haute",
"var_cloud_high_short": "Haute",
"var_precipitation": "Précipitations",
"var_precipitation_short": "Précip.",
"var_pop": "Probabilité de précip.",
"var_pop_short": "Prob.",
"var_rain": "Pluie",
"var_rain_short": "Pluie",
"var_showers": "Averses",
"var_showers_short": "Averses",
"var_snowfall": "Chutes de neige",
"var_snowfall_short": "Neige",
"var_wind": "Vitesse du vent",
"var_wind_short": "Vent",
"var_wind_dir": "Direction du vent",
"var_wind_dir_short": "Dir.",
"var_gusts": "Rafales",
"var_gusts_short": "Rafales",
"var_humidity": "Humidité",
"var_humidity_short": "HR",
"var_pressure": "Pression (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Pression au sol",
"var_surface_pressure_short": "Psfc",
"var_uv": "Indice UV",
"var_uv_short": "UV",
"var_visibility": "Visibilité",
"var_visibility_short": "Vis.",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Heure",
"no_data_title": "Ce modèle n'a pas de données ici",
"no_data_body": "Le modèle sélectionné ne couvre pas {location} : les modèles régionaux ne fournissent des données que dans leur propre zone.",
"no_data_try_city": "Changer de lieu pour {city}",
"no_data_best_match": "Passer à « Best match »",
"model_archive": "Réanalyse",
"model_seasonal": "Modèle saisonnier",
"nearby_cities_title": "Villes à proximité",
"nearby_cities_subtitle": "Maximales et minimales du jour sélectionné",
"supporter_dialog_title": "Soutien Drizz.li",
"supporter_dialog_desc": "Collez la clé d'accès reçue par e-mail pour débloquer les bonus.",
"supporter_extras_active": "Les bonus contributeur sont actifs",
"supporter_lifetime": "Accès à vie",
"supporter_active_until": "Actif jusqu'au {date}",
"supporter_access_key": "Clé d'accès",
"supporter_key_invalid": "Cette clé n'est pas valide ou a expiré.",
"supporter_server_unreachable": "Serveur injoignable. Vérifiez votre connexion et réessayez.",
"supporter_verifying": "Vérification…",
"supporter_unlock_button": "Débloquer",
"supporter_remove_key": "Supprimer la clé de cet appareil",
"supporter_no_key_yet": "Pas encore de clé ?",
"supporter_support_from": "Soutenez le projet à partir de {price}",
"supporter_checking": "Vérification de votre abonnement…",
"supporter_gate_title": "{feature} est un bonus contributeur",
"supporter_gate_body": "Drizz.li est gratuit et open source. Contribuez à partir de {price} pour le faire vivre - en remerciement, les contributeurs débloquent les bonus.",
"supporter_become": "Devenir contributeur",
"supporter_have_key": "J'ai une clé",
"supporter_key_expired": "Votre clé enregistrée n'est plus valide ou a expiré.",
"supporter_enter_key": "Saisissez votre clé d'accès.",
"supporter_perk_historical": "Météo historique et comparaison aux normales climatiques",
"supporter_perk_seasonal": "Aperçu saisonnier : les mois à venir face à la normale climatique",
"supporter_perk_future": "Les nouveaux bonus dès leur sortie",
"action_close": "Fermer",
"action_done": "Terminé",
"action_try_again": "Réessayer",
"action_reset_defaults": "Rétablir les valeurs par défaut",
"customize_meteograms": "Personnaliser les météogrammes",
"customizer_intro": "Faites glisser les variables d'un graphique à l'autre pour composer votre mise en page.",
"customizer_chart_n": "Graphique {number}",
"customizer_delete_chart": "Supprimer le graphique {number}",
"customizer_add_chart": "+ Ajouter un graphique",
"customizer_available": "Variables disponibles",
"customizer_drag": "Déplacer {variable}",
"customizer_remove": "Retirer {variable}",
"customizer_drop_here": "Déposez des variables ici",
"customizer_all_in_use": "Toutes les variables sont utilisées",
"variables_aria": "Sélection des variables",
"variables_close": "Fermer la sélection des variables",
"variables_table_section": "Tableau horaire",
"variables_move_up": "Déplacer {variable} vers le haut",
"variables_move_down": "Déplacer {variable} vers le bas",
"variables_charts_hint_before": "Les variables des météogrammes se règlent avec le bouton",
"variables_charts_hint_after": "au-dessus des graphiques.",
"table_customize": "Personnaliser les variables",
"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",
"chart_toggle_series": "Afficher ou masquer {series}",
"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 des prévisions hebdomadaires.",
"search_searching": "Recherche…",
"search_favorites": "Favoris",
"search_recent": "Récents",
"search_hint": "Commencez à taper ou utilisez le GPS pour détecter votre position",
"search_no_results": "Aucun lieu trouvé",
"search_remove_recent": "Retirer {location} des lieux récents",
"search_remove_recent_short": "Retirer des récents",
"search_aria": "Rechercher un lieu",
"search_gps": "Utiliser la position GPS",
"model_automatic_selection": "Sélection automatique",
"model_selector_aria": "Sélection : {label}",
"compare_models_heading": "Modèles",
"compare_models_choose": "Choisir les modèles à comparer",
"compare_variables_heading": "Variables météo horaires",
"compare_standard_preset": "Comparaison standard",
"compare_weather_conditions": "Conditions & couverture nuageuse",
"compare_wind_direction_height": "Direction du vent à {height} m",
"compare_standard_preset_description": "Un point de départ équilibré pour comparer les modèles de prévision.",
"compare_preset_active": "Active",
"compare_custom_selection": "Personnalisée · {count} sélectionnées",
"compare_customize_variables": "Personnaliser les variables",
"compare_restore_defaults": "Rétablir la sélection standard",
"compare_variables_pending": "Sélection des variables modifiée",
"compare_selected_models": "Modèles sélectionnés",
"compare_selection_pending": "Sélection modifiée",
"compare_apply_selection": "Appliquer et recharger les graphiques",
"compare_discard_changes": "Annuler les modifications",
"compare_customize_models": "Personnaliser les modèles",
"compare_edit_models": "Modifier les modèles",
"compare_edit_variables": "Modifier les variables",
"compare_edit_models_description": "Recherchez des modèles ou sélectionnez des fournisseurs entiers.",
"compare_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques entiers.",
"compare_search_models": "Rechercher des modèles",
"compare_search_variables": "Rechercher des variables",
"compare_show_model_names": "Afficher les noms des modèles",
"compare_hide_model_names": "Masquer les noms des modèles",
"compare_only_selected": "Sélection uniquement",
"compare_select_group": "Tout sélectionner",
"compare_clear_group": "Tout désélectionner",
"compare_no_matching_options": "Aucune option correspondante.",
"compare_no_models_selected": "Aucun modèle sélectionné.",
"compare_remove_model": "Supprimer {model}",
"compare_timeline_title": "Chronologie de comparaison des modèles",
"compare_model_label": "Modèle",
"page_compare_title": "Comparaison des modèles météo",
"page_compare_description": "Comparez les prévisions horaires de plusieurs modèles météo pour nimporte quel lieu.",
"compare_direction_subtitle": "Dispersion des directions sur {count} modèles · sans moyenne",
"compare_scalar_subtitle": "Sur {count} modèles · pointillés = moyenne des modèles",
"compare_precipitation_subtitle": "Sur {count} modèles · bande inférieure = accord sur les précipitations",
"compare_precipitation_agreement": "Accord",
"compare_precipitation_agreement_tooltip": "{wet}/{available} avec précipitations · médiane {median} {unit} · plage {min}{max} {unit}",
"compare_model_mean": "Moyenne des modèles",
"compare_showing_previous": "La dernière comparaison réussie reste affichée.",
"compare_empty_selection": "Sélectionnez au moins un modèle et une variable météo.",
"compare_no_data": "Aucun des modèles sélectionnés ne fournit de données exploitables pour ce lieu et cette sélection.",
"compare_weather_codes_only": "Les conditions et la couverture nuageuse sont affichées dans la chronologie ci-dessous. Sélectionnez une autre variable pour ajouter un graphique.",
"compare_model_colors": "Couleurs des modèles",
"compare_group_temperature": "Température et humidité",
"compare_group_precipitation": "Précipitations et conditions",
"compare_group_clouds": "Pression et nuages",
"compare_group_wind": "Vent et atmosphère",
"compare_group_upper_air": "Température en altitude",
"compare_many_models_warning": "{count} modèles sont sélectionnés. La comparaison peut être plus lente et les couleurs plus difficiles à distinguer.",
"compare_timeline_hint": "Conditions par modèle ; le fond des pictogrammes indique la couverture nuageuse totale",
"compare_timeline_scroll_aria": "Chronologie météo des modèles à défilement horizontal",
"compare_timeline_caption": "Conditions météo horaires par modèle de prévision",
"ensemble_trimmed": "L'ensemble de ce modèle ne va qu'à environ {days} jours, la dispersion est donc limitée à sa portée disponible.",
"seasonal_explainer_before": "Une prévision saisonnière montre dans quelle mesure un mois entier devrait",
"seasonal_explainer_strong": "s'écarter de sa normale climatique",
"seasonal_explainer_after": "- et non le temps qu'il fera un jour donné. Lisez la tendance mensuelle et l'accord de l'ensemble, pas les variations quotidiennes.",
"seasonal_runs_to": "Cet aperçu va jusqu'au {date}.",
"seasonal_range_aria": "Période de l'aperçu",
"seasonal_no_normal": "pas de normale pour ce mois",
"normals_loading": "chargement de la normale…",
"normals_loading_period": "chargement de la normale 19912020…",
"normal_band_legend": "bande normale = moyenne 19912020",
"anomaly_vs_normal": "{value} par rapport à la normale",
"stat_avg_temperature": "Température moyenne",
"stat_mean_temperature": "Température moyenne",
"stat_total_precipitation": "Précipitations totales",
"stat_warmest_day": "Jour le plus chaud",
"stat_coldest_day": "Jour le plus froid",
"historical_daily_heading": "Quotidien",
"historical_daily_hint": " sélectionnez un jour pour le détail horaire",
"historical_full_range": " période complète",
"historical_from": "Du",
"historical_to": "Au",
"historical_quick_ranges": "Plages rapides",
"historical_last_days": "{days} jours",
"historical_month_last_year": "Ce mois, l'an dernier",
"label_day": "jour",
"label_night": "nuit",
"daycards_past": "3 jours passés",
"daycards_load_15": "Charger 15 jours",
"error_technical_details": "Détails techniques",
"err_network_title": "Service météo injoignable",
"err_network_hint": "Vérifiez votre connexion internet et réessayez.",
"err_nodata_title": "Aucune donnée pour ce lieu avec le modèle sélectionné",
"err_nodata_hint": "Les modèles régionaux ne couvrent que leur propre zone - \"Best match\" choisit automatiquement un modèle adapté.",
"err_rejected_title": "Le service météo a refusé la requête",
"err_rejected_hint": "Essayez d'autres réglages ou revenez au modèle \"Best match\".",
"err_generic_title": "Le chargement des données météo a échoué",
"err_generic_hint": "Réessayez dans un instant. Si cela persiste, revenez au modèle \"Best match\".",
"maps_iframe_title": "Carte interactive Open-Meteo",
"page_maps_title": "Carte météo",
"page_week_title": "Météo",
"search_favorite_add": "Ajouter aux favoris",
"search_favorite_remove": "Retirer des favoris",
"supporter_price_per_month": "{amount} / mois",
"model_best_match_hint": "Choisit automatiquement le meilleur modèle pour ce lieu",
"model_updated": "mis à jour {cadence}",
"cadence_every_hour": "toutes les heures",
"cadence_every_hours": "toutes les {hours} h",
"cadence_daily": "chaque jour",
"cadence_monthly": "chaque mois",
"cadence_varies": "variable",
"model_group_automatic": "Automatique",
"model_group_reanalysis": "Réanalyse ECMWF",
"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"
}
+422
View File
@@ -0,0 +1,422 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Previsioni settimanali",
"nav_compare": "Confronto modelli",
"nav_14day": "Previsioni a 14 giorni",
"nav_seasonal": "Stagionale",
"nav_historical": "Storico",
"nav_maps": "Mappe",
"nav_collapse": "Comprimi",
"nav_expand_sidebar": "Espandi barra laterale",
"nav_collapse_sidebar": "Comprimi barra laterale",
"nav_home": "Home di Drizzli",
"nav_toggle_menu": "Attiva/disattiva menu",
"search_placeholder": "Cerca una località…",
"settings_title": "Impostazioni",
"units_title": "Unità",
"units_aria": "Scegli le unità di misura",
"unit_temperature": "Temperatura",
"unit_wind_speed": "Velocità del vento",
"unit_precipitation": "Precipitazioni",
"theme_label": "Tema",
"theme_system": "Sistema",
"theme_light": "Chiaro",
"theme_dark": "Scuro",
"theme_follow_system": "Tema: segui il sistema",
"theme_light_title": "Tema: chiaro",
"theme_dark_title": "Tema: scuro",
"language_label": "Lingua",
"supporter_active": "Extra sostenitore attivi",
"supporter_support": "Sostieni Drizz.li",
"supporter_manage_key": "Gestisci la tua chiave di accesso",
"supporter_unlock": "Sblocca gli extra sostenitore",
"supporter_badge": "Sostenitore",
"strip_past_label": "prima",
"strip_past_aria": "Carica gli ultimi 3 giorni",
"strip_days_label": "giorni",
"strip_extend_aria": "Mostra le previsioni complete a 15 giorni",
"strip_history_label": "archivio",
"strip_history_aria": "Apri l'archivio meteo",
"strip_history_title": "Storico: qualsiasi data dal 1940",
"strip_seasonal_label": "stagionale",
"strip_seasonal_aria": "Apri le tendenze stagionali",
"strip_seasonal_title": "Tendenze stagionali: i prossimi mesi",
"day_today": "Oggi",
"day_tomorrow": "Domani",
"day_yesterday": "Ieri",
"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",
"meteograms_customize": "Personalizza",
"meteograms_zoom_hint": "trascina o",
"meteograms_zoom_hint_end": "+ scorri per ingrandire",
"range_today": "Oggi",
"range_selected_day": "Giorno selezionato",
"range_3_days": "3 giorni",
"range_5_days": "5 giorni",
"range_all": "Tutto",
"range_group_aria": "Intervallo temporale del grafico",
"reset_zoom": "Reimposta zoom",
"default_range_title": "Intervallo temporale predefinito",
"default_range_auto": "Auto",
"default_range_auto_hint": "3 giorni su cellulare, tutto su schermi ampi",
"summary_heading": "in parole",
"summary_no_data": "Nessun dato orario per questo giorno.",
"label_sunrise": "Alba",
"label_sunset": "Tramonto",
"label_daylight": "Ore di luce",
"label_uv_index": "Indice UV",
"label_moonrise": "Sorgere della luna",
"label_moonset": "Tramonto della luna",
"label_moon": "Luna",
"daylight_hours": "{hours} h {minutes} min",
"sunshine_share": "{percent}% di sole",
"uv_low": "Basso",
"uv_moderate": "Moderato",
"uv_high": "Alto",
"uv_very_high": "Molto alto",
"uv_extreme": "Estremo",
"moon_new": "Luna nuova",
"moon_waxing_crescent": "Luna crescente",
"moon_first_quarter": "Primo quarto",
"moon_waxing_gibbous": "Gibbosa crescente",
"moon_full": "Luna piena",
"moon_waning_gibbous": "Gibbosa calante",
"moon_last_quarter": "Ultimo quarto",
"moon_waning_crescent": "Luna calante",
"cond_clear": "sereno",
"cond_fair": "parzialmente nuvoloso",
"cond_cloudy": "coperto",
"cond_fog": "nebbioso",
"cond_drizzle": "con pioviggine",
"cond_rain": "piovoso",
"cond_snow": "nevoso",
"cond_thunder": "temporalesco",
"period_overnight": "durante la notte",
"period_morning": "al mattino",
"period_afternoon": "nel pomeriggio",
"period_evening": "in serata",
"footer_tagline": "Previsioni meteo rapide e senza fronzoli, basate su dati aperti.",
"footer_data_by": "Dati meteo da",
"footer_forecasts": "Previsioni",
"footer_popular": "Località popolari",
"footer_about_section": "Informazioni",
"model_weather": "Modello meteo",
"model_ensemble": "Modello d'insieme",
"sky_all_1": "{condition} per tutta la giornata.",
"sky_all_2": "Una giornata {condition} dall'inizio alla fine.",
"sky_all_3": "Il cielo resta {condition} per tutto il giorno.",
"sky_two_1": "{c1} {p1}, poi {c2} {p2}.",
"sky_two_2": "La giornata si apre {c1} {p1} prima di diventare {c2} {p2}.",
"sky_two_3": "Cielo {c1} {p1} e poi {c2} {p2}.",
"sky_three_1": "{c1} {p1}, poi {c2} {p2} e infine {c3} {p3}.",
"sky_three_2": "Inizia {c1} {p1}, diventa {c2} {p2} e finisce {c3} {p3}.",
"sky_three_3": "Da cielo {c1} {p1} a {c2} {p2}, per poi restare {c3} {p3}.",
"temp_1": "Massime intorno a {high} e minime di {low} durante la notte.",
"temp_2": "Le temperature salgono fino a {high} e tornano a {low} dopo il tramonto.",
"temp_3": "Tra {low} e {high} nel corso della giornata.",
"temp_feels_1": "Massime intorno a {high} e minime di {low}, ma con una percezione di {feels}.",
"temp_feels_2": "Fino a {high} sul termometro, percepiti {feels}, prima di scendere a {low}.",
"temp_feels_3": "Si arriva a {high} ma se ne percepiscono {feels}, con {low} più tardi.",
"precip_window_1": "Circa {amount} di precipitazioni, soprattutto {when}.",
"precip_window_2": "Sono attesi circa {amount}, concentrati {when}.",
"precip_window_3": "Il periodo più piovoso è {when}, per un totale di circa {amount}.",
"precip_spread_1": "Circa {amount} di precipitazioni distribuite nell'arco della giornata.",
"precip_spread_2": "Circa {amount} cadono a intermittenza durante il giorno.",
"precip_spread_3": "Rovesci a intermittenza, per un totale di circa {amount}.",
"precip_chance_1": "Perlopiù asciutto, con una probabilità di rovesci fino al {percent}%.",
"precip_chance_2": "Poca pioggia prevista, ma c'è il {percent}% di probabilità di un rovescio di passaggio.",
"precip_chance_3": "Un {percent}% di rischio rovesci, ma nulla da ombrello.",
"precip_dry_1": "Resterà asciutto tutto il giorno.",
"precip_dry_2": "Non è attesa una goccia.",
"precip_dry_3": "Asciutto dall'inizio alla fine.",
"wind_dir_1": "Vento da {direction} fino a {speed}.",
"wind_dir_2": "Una brezza da {direction} tocca i {speed}.",
"wind_dir_3": "Il vento si mantiene da {direction}, con punte vicine a {speed}.",
"wind_dir_gusts_1": "Vento da {direction} fino a {speed}, con raffiche a {gust}.",
"wind_dir_gusts_2": "Un vento da {direction} di {speed}, con raffiche fino a {gust}.",
"wind_dir_gusts_3": "Attesi {speed} da {direction}, con raffiche di {gust} a tratti.",
"wind_1": "Vento fino a {speed}.",
"wind_2": "La brezza tocca circa {speed}.",
"wind_3": "Aria in movimento fino a {speed}.",
"wind_gusts_1": "Vento fino a {speed}, con raffiche a {gust}.",
"wind_gusts_2": "Fino a {speed}, con raffiche che arrivano a {gust}.",
"wind_gusts_3": "Giornata ventosa: {speed} costanti, {gust} nelle raffiche.",
"calm_1": "Quasi assenza di vento.",
"calm_2": "L'aria resta quasi immobile.",
"calm_3": "Praticamente senza vento per tutto il giorno.",
"uv_1": "Indice UV fino a {value} ({label}): proteggiti a metà giornata.",
"uv_2": "Il sole picchia a mezzogiorno: UV {value}, {label}.",
"uv_3": "Meglio cercare ombra a metà giornata: l'UV arriva a {value} ({label}).",
"legal_about": "Informazioni",
"legal_imprint": "Note legali",
"legal_privacy": "Privacy",
"legal_terms": "Condizioni",
"legal_nav": "Informazioni legali",
"city_weather": "Meteo a {city}",
"var_temperature": "Temperatura",
"var_temperature_short": "Temp",
"var_icons": "Icone meteo",
"var_icons_short": "Icone",
"var_apparent": "Percepita",
"var_apparent_short": "Percepita",
"var_dew_point": "Punto di rugiada",
"var_dew_point_short": "Rugiada",
"var_cloud": "Nuvolosità",
"var_cloud_short": "Nuvole",
"var_cloud_low": "Nuvolosità bassa",
"var_cloud_low_short": "Bassa",
"var_cloud_mid": "Nuvolosità media",
"var_cloud_mid_short": "Media",
"var_cloud_high": "Nuvolosità alta",
"var_cloud_high_short": "Alta",
"var_precipitation": "Precipitazioni",
"var_precipitation_short": "Precip.",
"var_pop": "Probabilità di precip.",
"var_pop_short": "Prob.",
"var_rain": "Pioggia",
"var_rain_short": "Pioggia",
"var_showers": "Rovesci",
"var_showers_short": "Rovesci",
"var_snowfall": "Nevicate",
"var_snowfall_short": "Neve",
"var_wind": "Velocità del vento",
"var_wind_short": "Vento",
"var_wind_dir": "Direzione del vento",
"var_wind_dir_short": "Dir.",
"var_gusts": "Raffiche",
"var_gusts_short": "Raffiche",
"var_humidity": "Umidità",
"var_humidity_short": "UR",
"var_pressure": "Pressione (MSL)",
"var_pressure_short": "MSLP",
"var_surface_pressure": "Pressione al suolo",
"var_surface_pressure_short": "Psfc",
"var_uv": "Indice UV",
"var_uv_short": "UV",
"var_visibility": "Visibilità",
"var_visibility_short": "Vis.",
"var_cape": "CAPE",
"var_cape_short": "CAPE",
"var_time": "Ora",
"no_data_title": "Nessun dato per questo modello qui",
"no_data_body": "Il modello selezionato non copre {location}: i modelli regionali forniscono dati solo nella propria area.",
"no_data_try_city": "Cambia località in {city}",
"no_data_best_match": "Passa a «Best match»",
"model_archive": "Rianalisi",
"model_seasonal": "Modello stagionale",
"nearby_cities_title": "Città nelle vicinanze",
"nearby_cities_subtitle": "Massime e minime del giorno selezionato",
"supporter_dialog_title": "Sostenitore Drizz.li",
"supporter_dialog_desc": "Incolla la chiave di accesso ricevuta via e-mail per sbloccare gli extra.",
"supporter_extras_active": "Gli extra per sostenitori sono attivi",
"supporter_lifetime": "Accesso a vita",
"supporter_active_until": "Attivo fino al {date}",
"supporter_access_key": "Chiave di accesso",
"supporter_key_invalid": "Questa chiave non è valida o è scaduta.",
"supporter_server_unreachable": "Server non raggiungibile. Controlla la connessione e riprova.",
"supporter_verifying": "Verifica in corso…",
"supporter_unlock_button": "Sblocca",
"supporter_remove_key": "Rimuovi la chiave da questo dispositivo",
"supporter_no_key_yet": "Non hai ancora una chiave?",
"supporter_support_from": "Sostieni il progetto da {price}",
"supporter_checking": "Verifica dell'abbonamento…",
"supporter_gate_title": "{feature} è un extra per sostenitori",
"supporter_gate_body": "Drizz.li è gratuito e open source. Contribuisci da {price} per tenerlo in vita - come ringraziamento, i sostenitori sbloccano gli extra.",
"supporter_become": "Diventa sostenitore",
"supporter_have_key": "Ho una chiave",
"supporter_key_expired": "La chiave salvata non è più valida o è scaduta.",
"supporter_enter_key": "Inserisci la tua chiave di accesso.",
"supporter_perk_historical": "Meteo storico e confronto con le normali climatiche",
"supporter_perk_seasonal": "Prospettive stagionali: i mesi a venire rispetto alla normale climatica",
"supporter_perk_future": "Nuovi extra non appena arrivano",
"action_close": "Chiudi",
"action_done": "Fatto",
"action_try_again": "Riprova",
"action_reset_defaults": "Ripristina i valori predefiniti",
"customize_meteograms": "Personalizza i meteogrammi",
"customizer_intro": "Trascina le variabili tra i grafici per costruire il tuo layout.",
"customizer_chart_n": "Grafico {number}",
"customizer_delete_chart": "Elimina il grafico {number}",
"customizer_add_chart": "+ Aggiungi grafico",
"customizer_available": "Variabili disponibili",
"customizer_drag": "Trascina {variable}",
"customizer_remove": "Rimuovi {variable}",
"customizer_drop_here": "Trascina qui le variabili",
"customizer_all_in_use": "Tutte le variabili sono in uso",
"variables_aria": "Selezione delle variabili",
"variables_close": "Chiudi la selezione delle variabili",
"variables_table_section": "Tabella oraria",
"variables_move_up": "Sposta {variable} in alto",
"variables_move_down": "Sposta {variable} in basso",
"variables_charts_hint_before": "Le variabili dei meteogrammi si impostano con il pulsante",
"variables_charts_hint_after": "sopra i grafici.",
"table_customize": "Personalizza le variabili",
"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",
"chart_toggle_series": "Mostra o nascondi {series}",
"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 delle previsioni settimanali.",
"search_searching": "Ricerca in corso…",
"search_favorites": "Preferiti",
"search_recent": "Recenti",
"search_hint": "Inizia a digitare o usa il GPS per rilevare la tua posizione",
"search_no_results": "Nessuna località trovata",
"search_remove_recent": "Rimuovi {location} dai luoghi recenti",
"search_remove_recent_short": "Rimuovi dai recenti",
"search_aria": "Cerca una località",
"search_gps": "Usa la posizione GPS",
"model_automatic_selection": "Selezione automatica",
"model_selector_aria": "Selezione di {label}",
"compare_models_heading": "Modelli",
"compare_models_choose": "Scegli i modelli da confrontare",
"compare_variables_heading": "Variabili meteo orarie",
"compare_standard_preset": "Confronto standard",
"compare_weather_conditions": "Condizioni e copertura nuvolosa",
"compare_wind_direction_height": "Direzione del vento a {height} m",
"compare_standard_preset_description": "Un punto di partenza equilibrato per confrontare i modelli previsionali.",
"compare_preset_active": "Attivo",
"compare_custom_selection": "Personalizzata · {count} selezionate",
"compare_customize_variables": "Personalizza le variabili",
"compare_restore_defaults": "Ripristina la selezione standard",
"compare_variables_pending": "Selezione delle variabili modificata",
"compare_selected_models": "Modelli selezionati",
"compare_selection_pending": "Selezione modificata",
"compare_apply_selection": "Applica e ricarica i grafici",
"compare_discard_changes": "Annulla modifiche",
"compare_customize_models": "Personalizza modelli",
"compare_edit_models": "Modifica modelli",
"compare_edit_variables": "Modifica variabili",
"compare_edit_models_description": "Cerca modelli o seleziona intere famiglie di fornitori.",
"compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.",
"compare_search_models": "Cerca modelli",
"compare_search_variables": "Cerca variabili",
"compare_show_model_names": "Mostra nomi dei modelli",
"compare_hide_model_names": "Nascondi nomi dei modelli",
"compare_only_selected": "Solo selezionati",
"compare_select_group": "Seleziona tutto",
"compare_clear_group": "Deseleziona tutto",
"compare_no_matching_options": "Nessuna opzione corrispondente.",
"compare_no_models_selected": "Nessun modello selezionato.",
"compare_remove_model": "Rimuovi {model}",
"compare_timeline_title": "Cronologia del confronto tra modelli",
"compare_model_label": "Modello",
"page_compare_title": "Confronto dei modelli meteo",
"page_compare_description": "Confronta le previsioni orarie di più modelli meteo per qualsiasi località.",
"compare_direction_subtitle": "Dispersione della direzione tra {count} modelli · senza media",
"compare_scalar_subtitle": "Tra {count} modelli · tratteggiato = media dei modelli",
"compare_precipitation_subtitle": "Tra {count} modelli · fascia inferiore = concordanza sulle precipitazioni",
"compare_precipitation_agreement": "Concordanza",
"compare_precipitation_agreement_tooltip": "{wet}/{available} con precipitazioni · mediana {median} {unit} · intervallo {min}{max} {unit}",
"compare_model_mean": "Media dei modelli",
"compare_showing_previous": "Il confronto precedente rimane visibile.",
"compare_empty_selection": "Seleziona almeno un modello e una variabile meteo.",
"compare_no_data": "Nessuno dei modelli selezionati fornisce dati utilizzabili per questa località e selezione.",
"compare_weather_codes_only": "Le condizioni e la copertura nuvolosa sono mostrate nella cronologia in basso. Seleziona unaltra variabile per aggiungere un grafico.",
"compare_model_colors": "Colori dei modelli",
"compare_group_temperature": "Temperatura e umidità",
"compare_group_precipitation": "Precipitazioni e condizioni",
"compare_group_clouds": "Pressione e nuvole",
"compare_group_wind": "Vento e atmosfera",
"compare_group_upper_air": "Temperatura in quota",
"compare_many_models_warning": "Sono selezionati {count} modelli. Il confronto può essere più lento e i colori più difficili da distinguere.",
"compare_timeline_hint": "Condizioni per modello; lo sfondo dei pittogrammi indica la copertura nuvolosa totale",
"compare_timeline_scroll_aria": "Cronologia meteo scorrevole dei modelli",
"compare_timeline_caption": "Condizioni meteo orarie per modello di previsione",
"ensemble_trimmed": "L'ensemble di questo modello arriva solo a circa {days} giorni, quindi la dispersione è ridotta al periodo disponibile.",
"seasonal_explainer_before": "Una previsione stagionale mostra quanto un intero mese potrebbe",
"seasonal_explainer_strong": "discostarsi dalla sua normale climatica",
"seasonal_explainer_after": "- non il tempo di un singolo giorno. Guarda la tendenza mensile e l'accordo dell'ensemble, non gli sbalzi giornalieri.",
"seasonal_runs_to": "Queste prospettive arrivano fino al {date}.",
"seasonal_range_aria": "Periodo delle prospettive",
"seasonal_no_normal": "nessuna normale per questo mese",
"normals_loading": "caricamento della normale…",
"normals_loading_period": "caricamento della normale 19912020…",
"normal_band_legend": "banda normale = media 19912020",
"anomaly_vs_normal": "{value} rispetto alla normale",
"stat_avg_temperature": "Temperatura media",
"stat_mean_temperature": "Temperatura media",
"stat_total_precipitation": "Precipitazioni totali",
"stat_warmest_day": "Giorno più caldo",
"stat_coldest_day": "Giorno più freddo",
"historical_daily_heading": "Giornaliero",
"historical_daily_hint": " seleziona un giorno per il dettaglio orario",
"historical_full_range": " intervallo completo",
"historical_from": "Dal",
"historical_to": "Al",
"historical_quick_ranges": "Intervalli rapidi",
"historical_last_days": "{days} giorni",
"historical_month_last_year": "Questo mese, l'anno scorso",
"label_day": "giorno",
"label_night": "notte",
"daycards_past": "Ultimi 3 giorni",
"daycards_load_15": "Carica 15 giorni",
"error_technical_details": "Dettagli tecnici",
"err_network_title": "Servizio meteo non raggiungibile",
"err_network_hint": "Controlla la connessione a internet e riprova.",
"err_nodata_title": "Nessun dato per questa località con il modello selezionato",
"err_nodata_hint": "I modelli regionali coprono solo la propria area - \"Best match\" sceglie automaticamente un modello adatto.",
"err_rejected_title": "Il servizio meteo ha rifiutato la richiesta",
"err_rejected_hint": "Prova impostazioni diverse o torna al modello \"Best match\".",
"err_generic_title": "Caricamento dei dati meteo non riuscito",
"err_generic_hint": "Riprova tra un momento. Se continua, passa al modello \"Best match\".",
"maps_iframe_title": "Mappa interattiva di Open-Meteo",
"page_maps_title": "Mappa meteo",
"page_week_title": "Meteo",
"search_favorite_add": "Aggiungi ai preferiti",
"search_favorite_remove": "Rimuovi dai preferiti",
"supporter_price_per_month": "{amount} / mese",
"model_best_match_hint": "Sceglie automaticamente il modello migliore per questa località",
"model_updated": "aggiornato {cadence}",
"cadence_every_hour": "ogni ora",
"cadence_every_hours": "ogni {hours} h",
"cadence_daily": "ogni giorno",
"cadence_monthly": "ogni mese",
"cadence_varies": "variabile",
"model_group_automatic": "Automatico",
"model_group_reanalysis": "Rianalisi ECMWF",
"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"
}
+268 -42
View File
@@ -10,6 +10,7 @@
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@inlang/paraglide-js": "^2.23.0",
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.25.0",
"@sveltejs/adapter-static": "^3.0.10",
@@ -495,6 +496,64 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@inlang/paraglide-js": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.23.0.tgz",
"integrity": "sha512-q+FQisRAVQqyD+0fdVHPkC7UDfaXENUJqUjim7XGEzWHE6vVMUOO10MFtAOKx5VYAgi8o7rLwe4zjzesCY0Anw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inlang/recommend-sherlock": "^0.2.1",
"@inlang/sdk": "^2.10.0",
"commander": "11.1.0",
"consola": "3.4.0",
"json5": "2.2.3",
"unplugin": "^2.1.2",
"urlpattern-polyfill": "^10.0.0"
},
"bin": {
"paraglide-js": "bin/run.js"
},
"peerDependencies": {
"typescript": ">=5.6",
"vite": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@inlang/recommend-sherlock": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
"integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"comment-json": "^4.2.3"
}
},
"node_modules/@inlang/sdk": {
"version": "2.10.2",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.10.2.tgz",
"integrity": "sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lix-js/sdk": "0.4.10",
"@sinclair/typebox": "^0.31.17",
"kysely": "^0.28.12",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^14.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@internationalized/date": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
@@ -555,6 +614,32 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lix-js/sdk": {
"version": "0.4.10",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.10.tgz",
"integrity": "sha512-0dMInAJK/67guTG5rRZaCEhvzC5cCXENOjaePA5AqMXrCE97kaY7SRor9e2vnoGsFIiGqXKlT0MCIoZj36G0gg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@lix-js/server-protocol-schema": "0.1.1",
"dedent": "1.5.1",
"human-id": "^4.1.1",
"js-sha256": "^0.11.0",
"kysely": "^0.28.12",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@lix-js/server-protocol-schema": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
"integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@lucide/svelte": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
@@ -707,9 +792,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -727,9 +809,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -747,9 +826,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -767,9 +843,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -787,9 +860,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -807,9 +877,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -896,6 +963,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sinclair/typebox": {
"version": "0.31.30",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.30.tgz",
"integrity": "sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==",
"dev": true,
"license": "MIT"
},
"node_modules/@sqlite.org/sqlite-wasm": {
"version": "3.48.0-build4",
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz",
"integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"sqlite-wasm": "bin/index.js"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1138,9 +1222,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1158,9 +1239,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1178,9 +1256,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1198,9 +1273,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1896,6 +1968,13 @@
"node": ">= 0.4"
}
},
"node_modules/array-timsort": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
"dev": true,
"license": "MIT"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1994,6 +2073,40 @@
"node": ">=6"
}
},
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/comment-json": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz",
"integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-timsort": "^1.0.3",
"esprima": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/consola": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2078,6 +2191,21 @@
}
}
},
"node_modules/dedent": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
"integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
},
"peerDependenciesMeta": {
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2374,6 +2502,20 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -2603,6 +2745,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/human-id": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz",
"integrity": "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==",
"dev": true,
"license": "MIT",
"bin": {
"human-id": "dist/cli.js"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2687,6 +2839,13 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-sha256": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2728,6 +2887,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2755,6 +2927,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/kysely": {
"version": "0.28.17",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz",
"integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2912,9 +3094,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2936,9 +3115,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2960,9 +3136,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2984,9 +3157,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3779,6 +3949,18 @@
"node": ">=0.10.0"
}
},
"node_modules/sqlite-wasm-kysely": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz",
"integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==",
"dev": true,
"dependencies": {
"@sqlite.org/sqlite-wasm": "^3.48.0-build2"
},
"peerDependencies": {
"kysely": "*"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -4170,6 +4352,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4180,6 +4378,13 @@
"punycode": "^2.1.0"
}
},
"node_modules/urlpattern-polyfill": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4187,6 +4392,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
@@ -4407,6 +4626,13 @@
"vitest": "^4.0.0"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -19,6 +19,7 @@
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@inlang/paraglide-js": "^2.23.0",
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.25.0",
"@sveltejs/adapter-static": "^3.0.10",
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "de", "es", "fr", "it"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.4.0/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2.2.6/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Regenerates static/data/cities/ - the city lists the "nearby cities" section
* on the week page picks from.
*
* Source: GeoNames (CC BY 4.0) via the cities500 mirror.
*
* The full list of towns down to 20k inhabitants is ~1 MB, far too much to ship
* to a phone for one section, so it is cut into 10x10 degree tiles. A tile
* holds every town within NEAR_RADIUS_KM of anywhere inside it, plus the larger
* cities within FAR_RADIUS_KM - so one small fetch answers both "what is around
* the corner" and "what is the nearest place you would recognise" (which is all
* a location in, say, the Australian outback can offer). Tiles overlap by
* design; duplicating a few rows is cheaper than a second round trip.
*
* The ids are GeoNames ids, which is what Open-Meteo's geocoding API returns,
* so a row can be turned into a location route without another lookup.
*
* Run with: node scripts/build-cities.mjs
*/
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const SOURCE = 'https://raw.githubusercontent.com/lmfmaier/cities-json/master/cities500.json';
const MIN_POPULATION = 20_000;
/** every city of any size within this range of the tile */
const NEAR_RADIUS_KM = 400;
/** only cities above FAR_MIN_POPULATION out to here */
const FAR_RADIUS_KM = 1500;
const FAR_MIN_POPULATION = 300_000;
const TILE_DEGREES = 10;
const outDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'static', 'data', 'cities');
// CITIES_SOURCE_FILE lets you build from an already-downloaded copy, which is
// also the only way to run this behind a proxy that node doesn't pick up.
const localSource = process.env.CITIES_SOURCE_FILE;
const source = localSource
? JSON.parse(await readFile(localSource, 'utf8'))
: await fetch(SOURCE).then((res) => {
if (!res.ok) throw new Error(`source fetch failed: ${res.status}`);
return res.json();
});
const cities = source
.map((c) => ({
id: Number(c.id),
name: c.name,
country: c.country,
lat: Number(c.lat),
lon: Number(c.lon),
pop: Number(c.pop ?? 0)
}))
.filter((c) => Number.isFinite(c.lat) && Number.isFinite(c.lon) && c.pop >= MIN_POPULATION)
// biggest first, so a tile that gets truncated keeps the recognisable names
.sort((a, b) => b.pop - a.pop);
const toRad = Math.PI / 180;
/** Distance from a point to the nearest point of a lat/lon rectangle. */
function distanceToTileKm(lat, lon, latMin, latMax, lonMin, lonMax) {
const clampedLat = Math.min(latMax, Math.max(latMin, lat));
// longitude distance shrinks towards the poles, measured at the closest latitude
let dLon = 0;
if (lon < lonMin) dLon = lonMin - lon;
else if (lon > lonMax) dLon = lon - lonMax;
if (dLon > 180) dLon = 360 - dLon;
const dLat = Math.abs(clampedLat - lat);
const lonKm = dLon * 111.32 * Math.cos(clampedLat * toRad);
return Math.hypot(dLat * 111.32, lonKm);
}
const tiles = new Map();
for (let latIndex = 0; latIndex < 180 / TILE_DEGREES; latIndex++) {
for (let lonIndex = 0; lonIndex < 360 / TILE_DEGREES; lonIndex++) {
const latMin = -90 + latIndex * TILE_DEGREES;
const lonMin = -180 + lonIndex * TILE_DEGREES;
const bounds = [latMin, latMin + TILE_DEGREES, lonMin, lonMin + TILE_DEGREES];
const rows = [];
for (const c of cities) {
const dist = distanceToTileKm(c.lat, c.lon, ...bounds);
const inRange =
dist <= NEAR_RADIUS_KM || (c.pop >= FAR_MIN_POPULATION && dist <= FAR_RADIUS_KM);
if (!inRange) continue;
// tuples, not objects: same data, roughly half the bytes
rows.push([
c.id,
c.name,
c.country,
+c.lat.toFixed(3),
+c.lon.toFixed(3),
Math.round(c.pop / 1000)
]);
}
if (rows.length > 0) tiles.set(`${latIndex}_${lonIndex}`, rows);
}
}
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
let bytes = 0;
for (const [key, rows] of tiles) {
const json = JSON.stringify(rows);
bytes += json.length;
await writeFile(join(outDir, `${key}.json`), json, 'utf8');
}
// the index tells the client which tiles exist, so an empty ocean tile is a
// no-op instead of a 404
await writeFile(join(outDir, 'index.json'), JSON.stringify([...tiles.keys()]), 'utf8');
const largest = Math.max(...[...tiles.values()].map((r) => r.length));
console.log(
`wrote ${tiles.size} tiles (${(bytes / 1024 / 1024).toFixed(1)} MB total, ` +
`avg ${Math.round(bytes / tiles.size / 1024)} KB, largest ${largest} cities) to ${outDir}`
);
+6 -1
View File
@@ -1,8 +1,13 @@
<!doctype html>
<html lang="en">
<html lang="%paraglide.lang%">
<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 {
+16
View File
@@ -0,0 +1,16 @@
import { paraglideMiddleware } from '$lib/paraglide/server';
import type { Handle } from '@sveltejs/kit';
/**
* Runs for every prerendered page, so the HTML written to disk is already in the
* right language and carries a matching `<html lang>` - the client never has to
* repaint the page into its locale.
*/
export const handle: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
});
});
+10
View File
@@ -0,0 +1,10 @@
import { deLocalizeUrl } from '$lib/paraglide/runtime';
import type { Reroute } from '@sveltejs/kit';
/**
* The locale lives in the URL (`/de/weather/week/`), but the routes on disk are
* language-neutral. Stripping the prefix here lets one route tree serve every
* language - on the server, during prerendering and on client-side navigation.
*/
export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;
File diff suppressed because it is too large Load Diff
+9 -10
View File
@@ -33,19 +33,19 @@ export const CHART_COLORS = {
// ─── Utility: Detect column-type variables ───────────────────────────────────
/** Units that should be rendered as bar/column charts instead of lines. */
const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
const COLUMN_UNITS = new Set(['mm', 'cm', 'in', 'inch', 'MJ/m²']);
/**
* Returns true if the given unit should be rendered as a bar chart.
*/
export function isColumnUnit(unit: string): boolean {
return COLUMN_UNITS.has(unit);
return COLUMN_UNITS.has(unit.trim());
}
// ─── Data Processing Helpers ─────────────────────────────────────────────────
export interface AverageResult {
average: number[];
average: (number | null)[];
averageCount: number[];
}
@@ -62,7 +62,7 @@ export function calculateAverage(
variable: string,
timeLength: number
): AverageResult {
const average = new Array<number>(timeLength).fill(0);
const totals = new Array<number>(timeLength).fill(0);
const averageCount = new Array<number>(timeLength).fill(0);
for (const [model, values] of Object.entries(hourlyData)) {
@@ -71,18 +71,17 @@ export function calculateAverage(
for (const [index, val] of (values as number[]).entries()) {
if (val !== null && val !== undefined && isFinite(val)) {
average[index] += val;
if (index >= timeLength) continue;
totals[index] += val;
averageCount[index]++;
}
}
}
// Finalize average values
for (let i = 0; i < timeLength; i++) {
if (averageCount[i] > 0) {
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
}
}
const average = totals.map((total, i) =>
averageCount[i] > 0 ? Math.round((total / averageCount[i]) * 10) / 10 : null
);
return { average, averageCount };
}
+7 -1
View File
@@ -8,10 +8,16 @@
export {
default as CanvasChart,
setGroupHover,
setGroupRange,
registerGroupMember,
groupRange,
groupHover
} from './CanvasChart.svelte';
export type { ChartSeries } from './CanvasChart.svelte';
export type {
ChartAgreementPoint,
ChartAgreementStrip,
ChartSeries
} from './CanvasChart.svelte';
export { buildDaylightBands } from './bands';
export type { DaylightBand } from './bands';
@@ -18,6 +18,8 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import * as m from '$lib/paraglide/messages';
import type { Snippet } from 'svelte';
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -93,7 +95,7 @@
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="sr-only">Loading charts...</span>
<span class="sr-only">{m.charts_loading()}</span>
</div>
</div>
</div>
@@ -124,9 +126,9 @@
min-width: var(--chart-min-width);
}
/* Mobile: fit the chart to the viewport instead of forcing a min-width
/* Below lg: fit the chart to the viewport instead of forcing a min-width
sideways scroll (which fights touch inspection). Pinch to zoom for detail. */
@media (max-width: 767px) {
@media (max-width: 1023px) {
.chart-container {
min-width: 0;
}
@@ -135,7 +137,7 @@
}
}
@media (min-width: 768px) {
@media (min-width: 1024px) {
.chart-bleed {
margin-left: -1.5rem;
margin-right: -1.5rem;
+17 -9
View File
@@ -18,12 +18,15 @@
{/snippet}
</ChartToolbar>
-->
<script module lang="ts">
export type { DownloadableChart } from './downloadChartsPng';
</script>
<script lang="ts">
import { type DownloadableChart, downloadChartsPng } from './downloadChartsPng';
import * as m from '$lib/paraglide/messages';
import {
type ChartDownloadOptions,
type ChartExportItem,
type ExportableChart,
downloadChartsPng
} from './downloadChartsPng';
import type { Snippet } from 'svelte';
@@ -31,9 +34,11 @@
interface Props {
/** Chart components available for download (undefined entries are skipped) */
charts?: Array<DownloadableChart | 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.) */
@@ -43,6 +48,7 @@
let {
charts = [],
fileName = 'drizzli-chart',
exportOptions,
class: className = '',
controls
}: Props = $props();
@@ -53,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 ───────────────────────────────────────────────────────────────
@@ -61,7 +69,7 @@
if (!hasCharts || downloading) return;
downloading = true;
try {
await downloadChartsPng(charts, fileName);
await downloadChartsPng(charts, fileName, exportOptions);
} finally {
setTimeout(() => {
downloading = false;
@@ -87,7 +95,7 @@
class="toolbar-btn"
disabled={!hasCharts || downloading}
onclick={handleDownload}
title="Download meteogram as PNG image"
title={m.chart_download()}
>
{#if downloading}
<svg
+124 -27
View File
@@ -1,23 +1,31 @@
/**
* Shared PNG export for charts.
*
* Stitches one or more chart images vertically onto a single canvas (over the
* current theme background) and triggers a download. Used by both the standalone
* ChartToolbar and the inline toolbar buttons.
* Each chart composites itself (plot + icon bands + optional title + legend)
* onto a canvas via `getExportImage`; those are stacked vertically over the
* current theme background and downloaded as one PNG.
*/
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
export interface DownloadableChart {
getPngDataUrl(): string | null;
/** A chart that can composite itself (plot + icons + legend) for export. */
export interface ExportableChart {
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(img);
img.src = src;
});
/** One chart to export, with the title to render above it (e.g. panel name). */
export interface ChartExportItem {
chart: ExportableChart | null | undefined;
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. */
@@ -38,25 +46,71 @@ function triggerDownload(url: string, name: string): void {
});
}
/** Normalise either a bare chart or a {chart, title} item. */
function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartExportItem {
if (x && 'getExportImage' in x) return { chart: x };
return (x as ChartExportItem) ?? { chart: null };
}
/**
* Stitch the given charts into one PNG and download it. Resolves once the
* download has been triggered (or immediately if there is nothing to export).
*/
export async function downloadChartsPng(
charts: Array<DownloadableChart | undefined | null>,
fileName: string
charts: Array<ChartExportItem | ExportableChart | null | undefined>,
fileName: string,
options: ChartDownloadOptions = {}
): Promise<void> {
const dataUrls = charts
.filter((chart): chart is DownloadableChart => chart != null)
.map((chart) => chart.getPngDataUrl())
.filter((url): url is string => url !== null);
if (dataUrls.length === 0) return;
const items = charts
.map(toItem)
.filter(
(it): it is ChartExportItem & { chart: ExportableChart } =>
typeof it.chart?.getExportImage === 'function'
);
if (items.length === 0) return;
const images = (await Promise.all(dataUrls.map(loadImage))).filter((img) => img.naturalWidth > 0);
if (images.length === 0) return;
// 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(...images.map((img) => img.naturalWidth));
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
const maxWidth = Math.max(...canvases.map((c) => c.width));
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;
@@ -68,10 +122,53 @@ 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;
for (const img of images) {
ctx.drawImage(img, 0, y);
y += img.naturalHeight;
if (options.title) {
ctx.fillStyle = foreground;
ctx.font = `600 ${16 * dpr}px system-ui, -apple-system, sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillText(options.title, padding, 17 * dpr);
y += 34 * dpr;
}
for (const c of canvases) {
ctx.drawImage(c, 0, y);
y += c.height;
}
if (legendRows.length > 0) {
y += 8 * dpr;
ctx.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
ctx.textBaseline = 'middle';
for (const row of legendRows) {
let x = padding;
for (const item of row) {
const markerWidth = 12 * dpr;
ctx.strokeStyle = item.color;
ctx.fillStyle = item.color;
ctx.lineWidth = 2 * dpr;
ctx.setLineDash(item.style === 'dashed' ? [4 * dpr, 3 * dpr] : []);
if (item.style === 'point') {
ctx.beginPath();
ctx.arc(x + markerWidth / 2, y + rowHeight / 2, 3 * dpr, 0, Math.PI * 2);
ctx.fill();
} else if (item.style === 'bar') {
ctx.fillRect(x + 3 * dpr, y + 5 * dpr, 6 * dpr, 12 * dpr);
} else {
ctx.beginPath();
ctx.moveTo(x, y + rowHeight / 2);
ctx.lineTo(x + markerWidth, y + rowHeight / 2);
ctx.stroke();
}
ctx.setLineDash([]);
x += markerWidth + 6 * dpr;
ctx.fillStyle = muted;
ctx.fillText(item.name, x, y + rowHeight / 2);
x += ctx.measureText(item.name).width + gap;
}
y += rowHeight;
}
}
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
+7 -1
View File
@@ -9,4 +9,10 @@
export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.svelte';
export { downloadChartsPng, type DownloadableChart } from './downloadChartsPng';
export {
downloadChartsPng,
type ChartDownloadOptions,
type ChartExportItem,
type ExportableChart,
type ExportLegendItem
} from './downloadChartsPng';
@@ -0,0 +1,53 @@
<script lang="ts">
import { page } from '$app/stores';
import { LOCALE_LABELS, LOCALE_LIST } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import { type Locale, getLocale, localizeHref } from '$lib/paraglide/runtime';
interface Props {
/** Called after a language is picked (used to close the menu around it). */
onSelect?: () => void;
}
let { onSelect }: Props = $props();
let current = $derived.by(() => {
// re-read on navigation: the URL is what decides the locale
void $page.url.pathname;
return getLocale();
});
/** The current page, in another language. */
function switchTo(locale: Locale): string {
return localizeHref($page.url.pathname + $page.url.search, { locale });
}
</script>
<div>
<span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
{m.language_label()}
</span>
<!-- Plain links, not buttons: the locale lives in the URL, so switching
language is a navigation. It also means each option is shareable and
crawlable. -->
<div class="grid grid-cols-2 gap-1">
{#each LOCALE_LIST as locale (locale)}
{@const active = current === locale}
<a
href={switchTo(locale)}
hreflang={locale}
data-sveltekit-reload
class="cursor-pointer rounded-md px-2 py-1.5 text-center text-[13px] font-semibold transition-colors {active
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'}"
aria-current={active ? 'true' : undefined}
onclick={onSelect}
>
{LOCALE_LABELS[locale]}
</a>
{/each}
</div>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import { page } from '$app/stores';
import LanguageOptions from '$lib/components/language-options.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as m from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
// the URL decides the locale, so re-read it on navigation
let current = $derived.by(() => {
void $page.url.pathname;
return getLocale();
});
let open = $state(false);
</script>
<Popover.Root bind:open>
<Popover.Trigger
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label={m.language_label()}
title={m.language_label()}
>
<!-- globe -->
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18Z" />
</svg>
<span class="uppercase">{current}</span>
</Popover.Trigger>
<Popover.Content align="end" class="w-56 border-border">
<LanguageOptions onSelect={() => (open = false)} />
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,29 @@
<script lang="ts">
import { page } from '$app/stores';
import { getLocale } from '$lib/paraglide/runtime';
import type { Component } from 'svelte';
/**
* Picks the content component for the current locale.
*
* Long-form pages (about, imprint, privacy, terms) are kept as one component
* per language rather than as message strings: the prose is full of inline
* links, lists and headings, and splitting that into placeholders makes both
* the source and the translations harder to read and to keep correct.
*/
interface Props {
variants: Record<string, Component>;
}
let { variants }: Props = $props();
let Content = $derived.by(() => {
// the URL decides the locale, so re-resolve on navigation
void $page.url.pathname;
return variants[getLocale()] ?? variants.en;
});
</script>
<Content />
@@ -13,6 +13,8 @@
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import * as m from '$lib/paraglide/messages';
export let label: string = 'Search location...';
export let placeholder: string = 'Enter city name...';
@@ -48,6 +50,12 @@
);
}
/** Drop a single entry from the recent list (favourites are unaffected). */
function removeRecent(loc: GeoLocation) {
const key = locationKey(loc);
storedRecentLocations.update((list) => list.filter((l) => locationKey(l) !== key));
}
function toggleFavorite(loc: GeoLocation) {
const key = locationKey(loc);
storedFavoriteLocations.update((list) =>
@@ -126,7 +134,7 @@
})();
</script>
{#snippet locationRow(location: GeoLocation)}
{#snippet locationRow(location: GeoLocation, removable: boolean)}
{@const fav = favKeys.has(locationKey(location))}
<div
class="group flex items-center rounded-md border border-transparent transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
@@ -155,8 +163,8 @@
? 'text-amber-500'
: 'text-muted-foreground/50'}"
onclick={() => toggleFavorite(location)}
aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
title={fav ? 'Remove from favorites' : 'Add to favorites'}
aria-label={fav ? m.search_favorite_remove() : m.search_favorite_add()}
title={fav ? m.search_favorite_remove() : m.search_favorite_add()}
>
<svg
class="h-4 w-4"
@@ -172,6 +180,18 @@
/>
</svg>
</button>
{#if removable}
<button
class="mr-1 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/50 hover:bg-background hover:text-destructive"
onclick={() => removeRecent(location)}
aria-label={m.search_remove_recent({ location: location.name })}
title={m.search_remove_recent_short()}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
{/if}
</div>
{/snippet}
@@ -213,7 +233,7 @@
class="h-9"
autocomplete="off"
spellcheck="false"
aria-label="Search Location"
aria-label={m.search_aria()}
bind:value={searchQuery}
bind:ref={searchInputEl}
/>
@@ -222,7 +242,7 @@
variant="outline"
size="default"
class="h-9 px-2.5"
title="Use GPS Location"
title={m.search_gps()}
onclick={() => (searchQuery = 'GPS')}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -248,7 +268,7 @@
<div class="flex h-20 items-center justify-center">
<div class="flex items-center space-x-2">
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
<span class="text-sm text-muted-foreground">Searching...</span>
<span class="text-sm text-muted-foreground">{m.search_searching()}</span>
</div>
</div>
{:then results}
@@ -258,11 +278,11 @@
<div
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
Favorites
{m.search_favorites()}
</div>
<div class="space-y-0.5">
{#each $storedFavoriteLocations as loc (locationKey(loc))}
{@render locationRow(loc)}
{@render locationRow(loc, false)}
{/each}
</div>
{/if}
@@ -272,11 +292,11 @@
? 'mt-3'
: ''}"
>
Recent
{m.search_recent()}
</div>
<div class="space-y-0.5">
{#each recentToShow as loc (locationKey(loc))}
{@render locationRow(loc)}
{@render locationRow(loc, true)}
{/each}
</div>
{/if}
@@ -298,14 +318,14 @@
/>
</svg>
<span class="text-xs">
Start typing to search or use GPS to detect your position
{m.search_hint()}
</span>
</div>
{/if}
{:else if results.results && results.results.length > 0}
<div class="space-y-0.5">
{#each results.results as location, i (i)}
{@render locationRow(location)}
{@render locationRow(location, false)}
{/each}
</div>
{:else if results.results}
@@ -318,7 +338,7 @@
</Alert.Root>
{:else}
<Alert.Root variant="destructive">
<Alert.Description>No locations found</Alert.Description>
<Alert.Description>{m.search_no_results()}</Alert.Description>
</Alert.Root>
{/if}
{:catch error}
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import { href } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import LogoMark from './logo-mark.svelte';
// A hand-picked set of the prerendered city pages (see
// routes/weather/locations/city-names100.json) — instant navigation targets
// that double as SEO entry points.
const popularCities: { slug: string; label: string }[] = [
{ slug: 'london', label: 'London' },
{ slug: 'tokyo', label: 'Tokyo' },
{ slug: 'berlin', label: 'Berlin' },
{ slug: 'sydney', label: 'Sydney' },
{ slug: 'singapore', label: 'Singapore' },
{ slug: 'dubai', label: 'Dubai' },
{ slug: 'los-angeles', label: 'Los Angeles' },
{ slug: 'hong-kong', label: 'Hong Kong' },
{ slug: 'istanbul', label: 'Istanbul' },
{ slug: 'seoul', label: 'Seoul' },
{ slug: 'mexico-city', label: 'Mexico City' },
{ slug: 'sao-paulo', label: 'São Paulo' }
];
const forecastLinks = [
{ href: href('/weather/week'), label: m.nav_week() },
{ href: href('/weather/compare'), label: m.nav_compare() },
{ href: href('/weather/14-day'), label: m.nav_14day() },
{ href: href('/weather/seasonal'), label: m.page_seasonal_subtitle() },
{ href: href('/weather/historical'), label: m.page_historical_subtitle() },
{ href: href('/weather/maps'), label: m.nav_maps() }
];
const year = new Date().getFullYear();
</script>
<footer class="mt-16 border-t border-border bg-card/60">
<div class="mx-auto w-full max-w-[1536px] px-4 py-10 lg:px-8">
<div class="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
<!-- Brand -->
<div class="max-w-xs">
<div class="flex items-center gap-2">
<span
class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"
>
<LogoMark />
</span>
<span class="text-lg font-bold tracking-tight">Drizz.li</span>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">
{m.footer_tagline()}
</p>
<p class="mt-3 text-xs text-muted-foreground">
{m.footer_data_by()}
<a
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://open-meteo.com"
target="_blank"
rel="noopener noreferrer">Open-Meteo</a
>
</p>
</div>
<!-- Forecasts -->
<nav aria-label={m.footer_forecasts()}>
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
{m.footer_forecasts()}
</h3>
<ul class="mt-3 flex flex-col gap-2 text-sm">
{#each forecastLinks as link (link.href)}
<li>
<a
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
href={link.href}>{link.label}</a
>
</li>
{/each}
</ul>
</nav>
<!-- Popular locations (spans two columns of links) -->
<nav aria-label={m.footer_popular()} class="lg:col-span-2">
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
{m.footer_popular()}
</h3>
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
{#each popularCities as city (city.slug)}
<li>
<a
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
href={href('/weather/week/[location]', { location: city.slug })}
>{m.city_weather({ city: city.label })}</a
>
</li>
{/each}
</ul>
</nav>
</div>
<!-- Bottom bar: legal -->
<div
class="mt-10 flex flex-col items-start justify-between gap-3 border-t border-border/70 pt-5 text-xs text-muted-foreground sm:flex-row sm:items-center"
>
<span>© {year} Drizz.li</span>
<nav aria-label={m.legal_nav()} class="flex flex-wrap items-center gap-x-5 gap-y-1">
<a class="underline-offset-2 hover:text-foreground hover:underline" href={href('/about')}
>{m.legal_about()}</a
>
<a
class="underline-offset-2 hover:text-foreground hover:underline"
href={href('/legal/imprint')}>{m.legal_imprint()}</a
>
<a
class="underline-offset-2 hover:text-foreground hover:underline"
href={href('/legal/privacy')}>{m.legal_privacy()}</a
>
<a
class="underline-offset-2 hover:text-foreground hover:underline"
href={href('/legal/terms')}>{m.legal_terms()}</a
>
</nav>
</div>
</div>
</footer>
+105 -81
View File
@@ -2,16 +2,24 @@
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { type GeoLocation, type Theme, storedLocation, storedTheme } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import LanguageSelector from '$lib/components/language-selector.svelte';
import LocationSearch from '$lib/components/location/location-search.svelte';
import UnitSelector from '$lib/components/unit-selector.svelte';
import { href, routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
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';
interface Props {
onMenuToggle?: () => void;
}
@@ -24,11 +32,36 @@
location = value;
});
// Prerendered pages bake the DEFAULT location's flag into the HTML, and
// Svelte's hydration repairs text but not attributes — so on pages that
// never update the store (legal pages etc.) the stale flag would stick
// around next to the correct location name. Re-sync the src after mount.
let flagEl = $state<HTMLImageElement>();
$effect(() => {
const src = `/images/country-flags/${(location.country_code || 'united_nations').toLowerCase()}.svg`;
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: 'Theme: follow system',
light: 'Theme: light',
dark: 'Theme: dark'
const themeTitles: Record<Theme, () => string> = {
system: m.theme_follow_system,
light: m.theme_light_title,
dark: m.theme_dark_title
};
function cycleTheme() {
@@ -40,14 +73,18 @@
function navigateToLocation(newLocation: GeoLocation) {
storedLocation.set(newLocation);
const locationRoute = buildLocationRoute(newLocation);
const currentPath = get(page).url.pathname;
const currentPath = routePath(get(page).url.pathname);
if (currentPath.startsWith('/weather/compare')) {
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
goto(href('/weather/compare/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/14-day')) {
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
goto(href('/weather/14-day/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/historical')) {
goto(href('/weather/historical/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/seasonal')) {
goto(href('/weather/seasonal/[location]', { location: locationRoute }));
} else {
goto(resolve('/weather/week/[location]', { location: locationRoute }));
goto(href('/weather/week/[location]', { location: locationRoute }));
}
}
</script>
@@ -55,107 +92,94 @@
<header
class="topbar flex h-14 shrink-0 items-center gap-3 border-b border-topbar-border bg-topbar px-3 md:px-4"
>
<!-- Mobile menu toggle -->
<button
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-black/5 md:hidden dark:hover:bg-white/10"
onclick={onMenuToggle}
aria-label="Toggle menu"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Mobile menu toggle. On phones this side and the settings side both take
an equal share of the leftover width, which lands the search box dead
centre; on md+ they collapse and the spacer below does the work. -->
<div class="flex flex-1 items-center md:flex-none">
<button
class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
onclick={onMenuToggle}
aria-label={m.nav_toggle_menu()}
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<!-- 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}
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country}
/>
<!-- full location (desktop); the page hero carries it on smaller screens -->
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
<span class="min-w-0 truncate text-sm font-semibold text-foreground" title={locationLine}>
{location.name}
{#if location.admin1 || location.country}
<span class="font-normal text-muted-foreground">
· {#if location.admin1}{location.admin1},&nbsp;
{/if}{location.country ?? ''}
</span>
{#if locationDetail}
<span class="font-normal text-muted-foreground">· {locationDetail}</span>
{/if}
</span>
</div>
{/if}
<!-- Spacer -->
<div class="flex-1"></div>
<!-- Spacer (md+ only: on phones the equal side columns centre the search) -->
<div class="hidden flex-1 md:block"></div>
<!-- Location search: primary way to switch places, so keep it loud -->
<div class="w-full max-w-sm md:max-w-md">
<LocationSearch
label="Search location..."
label={m.search_placeholder()}
on:location={(event) => {
navigateToLocation(event.detail);
}}
/>
</div>
<!-- Measurement units -->
<UnitSelector />
<!-- Phones only have room for one control, so units, theme and supporter
status collapse into a single settings menu below md. This side mirrors
the menu-button column so the search lands dead centre. -->
<div class="flex flex-1 justify-end md:hidden">
<SettingsMenu />
</div>
<!-- Theme toggle: system → light → dark -->
<button
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={cycleTheme}
title={themeTitles[$storedTheme]}
aria-label={themeTitles[$storedTheme]}
>
{#if $storedTheme === 'light'}
<!-- sun -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
/>
</svg>
{:else if $storedTheme === 'dark'}
<!-- moon -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
/>
</svg>
{:else}
<!-- monitor (system) -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<rect x="3" y="4" width="18" height="13" rx="2" />
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
</svg>
<!-- 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 (hidden while supporter features are parked) -->
{#if SUPPORTER_ENABLED}
<SupporterBadge />
{/if}
</button>
<!-- Language: the locale lives in the URL, so this is a set of links -->
<LanguageSelector />
<!-- Measurement units -->
<UnitSelector />
<!-- Theme toggle: system → light → dark -->
<button
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={cycleTheme}
title={themeTitles[$storedTheme]()}
aria-label={themeTitles[$storedTheme]()}
>
<ThemeIcon theme={$storedTheme} />
</button>
</div>
</header>
<style>
@@ -0,0 +1,21 @@
<script lang="ts">
// umbrella-with-rain mark (matches the favicon); shared by the sidebar home
// link and the footer so the brand reads the same in both places
interface Props {
class?: string;
}
let { class: className = 'h-5 w-5' }: Props = $props();
</script>
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<!-- rain falls from above onto the canopy -->
<path stroke-linecap="round" d="M4.5 3v.01M19.5 3v.01M8.5 1.5v.01M15.5 2.5v.01" />
<path stroke-linecap="round" d="M12 4.5V6" />
<path
fill="currentColor"
stroke="none"
d="M4 14a8 8 0 0 1 16 0c-.66-1-1.99-1-2.66 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0C8.66 13 7.33 13 6.66 14 6 13 4.66 13 4 14Z"
/>
<path stroke-linecap="round" d="M12 14v5a1.9 1.9 0 0 1-3.8 0" />
</svg>
@@ -0,0 +1,112 @@
<script lang="ts">
import { type Theme, storedTheme } from '$lib/stores/settings';
import LanguageOptions from '$lib/components/language-options.svelte';
import * as Popover from '$lib/components/ui/popover';
import UnitOptions from '$lib/components/unit-options.svelte';
import * as m from '$lib/paraglide/messages';
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';
// The topbar has room for one control on a phone, so units, theme and the
// supporter status share this menu instead of each carrying its own trigger.
const THEMES: { value: Theme; label: () => string }[] = [
{ value: 'system', label: m.theme_system },
{ value: 'light', label: m.theme_light },
{ value: 'dark', label: m.theme_dark }
];
let open = $state(false);
let unlockOpen = $state(false);
</script>
<Popover.Root bind:open>
<Popover.Trigger
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label={m.settings_title()}
title={m.settings_title()}
>
<!-- gear -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<circle cx="12" cy="12" r="3" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2v.2a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.2-2.9H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"
/>
</svg>
</Popover.Trigger>
<Popover.Content align="end" class="w-72 border-border">
<div class="flex flex-col gap-4">
<UnitOptions />
<div>
<span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
{m.theme_label()}
</span>
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each THEMES as option (option.value)}
{@const active = $storedTheme === option.value}
<button
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={active}
onclick={() => storedTheme.set(option.value)}
>
<ThemeIcon theme={option.value} class="h-4 w-4" />
{option.label()}
</button>
{/each}
</div>
</div>
<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"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
onclick={() => {
open = false;
unlockOpen = true;
}}
>
<SupporterIcon
filled={$isSupporter}
class="h-4.5 w-4.5 shrink-0 {$isSupporter
? 'text-amber-500'
: 'text-muted-foreground'}"
/>
<span class="min-w-0 flex-1">
<span class="block text-[13px] font-semibold">
{$isSupporter ? m.supporter_active() : m.supporter_support()}
</span>
<span class="block text-[11px] text-muted-foreground">
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
</span>
</span>
</button>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
<UnlockDialog bind:open={unlockOpen} />
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Theme } from '$lib/stores/settings';
interface Props {
theme: Theme;
class?: string;
}
let { theme, class: className = 'h-4.5 w-4.5' }: Props = $props();
</script>
{#if theme === 'light'}
<!-- sun -->
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
/>
</svg>
{:else if theme === 'dark'}
<!-- moon -->
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
/>
</svg>
{:else}
<!-- monitor (system) -->
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<rect x="3" y="4" width="18" height="13" rx="2" />
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
</svg>
{/if}
@@ -1,7 +1,15 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import { href, routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import LogoMark from './logo-mark.svelte';
interface Props {
collapsed?: boolean;
onToggle?: () => void;
@@ -12,26 +20,43 @@
const links = [
{
title: '7-Day Forecast',
title: m.nav_week,
url: '/weather/week' as const,
route: '/weather/week/[location]' as const,
iconPaths: [
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'
]
},
{
title: 'Model Comparison',
title: m.nav_compare,
url: '/weather/compare' as const,
route: '/weather/compare/[location]' as const,
iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6']
},
{
title: '14-Day Forecast',
title: m.nav_14day,
url: '/weather/14-day' as const,
route: '/weather/14-day/[location]' as const,
iconPaths: [
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
]
},
{
title: 'Maps',
title: m.nav_seasonal,
url: '/weather/seasonal' as const,
route: '/weather/seasonal/[location]' as const,
// rising trend line (long-range outlook)
iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5']
},
{
title: m.nav_historical,
url: '/weather/historical' as const,
route: '/weather/historical/[location]' as const,
// clock with a counter-clockwise arrow (history)
iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
},
{
title: m.nav_maps,
url: '/weather/maps' as const,
// Heroicons "map" outline icon
iconPaths: [
@@ -40,7 +65,13 @@
}
];
let currentPath = $derived($page.url.pathname);
// the URL carries a locale prefix; compare the neutral path behind it
let currentPath = $derived(routePath($page.url.pathname));
// Link straight to the location page instead of the bare redirect route: it
// saves a navigation, and the page cross-fade can then wait for the real
// page's data instead of flashing through an empty redirect stub.
let locationRoute = $derived(buildLocationRoute($storedLocation));
const isActive = (url: string) => {
return currentPath === url || currentPath.startsWith(url + '/');
@@ -56,38 +87,21 @@
home link fills the entire row, padding included -->
<div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border">
<a
href={resolve('/weather/week')}
href={href('/weather/week/[location]', { location: locationRoute })}
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
? 'justify-center'
: 'px-4'}"
onclick={onMobileClose}
aria-label="Drizzli home"
aria-label={m.nav_home()}
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
>
<!-- umbrella-with-rain logo mark (matches favicon) -->
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<!-- rain falls from above onto the canopy -->
<path stroke-linecap="round" d="M4.5 3v.01M19.5 3v.01M8.5 1.5v.01M15.5 2.5v.01" />
<path stroke-linecap="round" d="M12 4.5V6" />
<path
fill="currentColor"
stroke="none"
d="M4 14a8 8 0 0 1 16 0c-.66-1-1.99-1-2.66 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0C8.66 13 7.33 13 6.66 14 6 13 4.66 13 4 14Z"
/>
<path stroke-linecap="round" d="M12 14v5a1.9 1.9 0 0 1-3.8 0" />
</svg>
<LogoMark />
</div>
{#if !collapsed}
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
Drizzli
Drizz.li
</span>
{/if}
</a>
@@ -95,14 +109,14 @@
<!-- Navigation links -->
<nav class="flex-1 space-y-1 px-2 py-3">
{#each links as link (link.title)}
{#each links as link (link.url)}
{@const active = isActive(link.url)}
<a
href={resolve(link.url)}
href={link.route ? href(link.route, { location: locationRoute }) : href(link.url)}
class="relative flex items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100 {active
? 'bg-sidebar-accent text-sidebar-primary! opacity-100! font-semibold! nav-active'
: ''}"
title={collapsed ? link.title : undefined}
title={collapsed ? link.title() : undefined}
onclick={onMobileClose}
>
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
@@ -119,19 +133,49 @@
</svg>
</div>
{#if !collapsed}
<span class="ml-2.5 whitespace-nowrap">{link.title}</span>
<span class="ml-2.5 whitespace-nowrap">{link.title()}</span>
{/if}
</a>
{/each}
</nav>
<!-- On phones the footer sits a long scroll away, so the same about/legal
links get a quiet home at the bottom of the drawer. -->
{#if onMobileClose}
<nav
aria-label={m.legal_nav()}
class="flex flex-wrap gap-x-3 gap-y-1 border-t border-sidebar-border px-4 py-3 text-[11px] text-sidebar-foreground/70"
>
<a
class="hover:text-sidebar-foreground hover:underline"
href={href('/about')}
onclick={onMobileClose}>{m.legal_about()}</a
>
<a
class="hover:text-sidebar-foreground hover:underline"
href={href('/legal/imprint')}
onclick={onMobileClose}>{m.legal_imprint()}</a
>
<a
class="hover:text-sidebar-foreground hover:underline"
href={href('/legal/privacy')}
onclick={onMobileClose}>{m.legal_privacy()}</a
>
<a
class="hover:text-sidebar-foreground hover:underline"
href={href('/legal/terms')}
onclick={onMobileClose}>{m.legal_terms()}</a
>
</nav>
{/if}
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
{#if onToggle}
<div class="border-t border-sidebar-border px-2 py-3">
<button
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
class="relative flex w-full cursor-pointer items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
onclick={onToggle}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
title={collapsed ? m.nav_expand_sidebar() : m.nav_collapse_sidebar()}
>
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
<svg
@@ -150,7 +194,7 @@
</svg>
</div>
{#if !collapsed}
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
<span class="ml-2.5 whitespace-nowrap">{m.nav_collapse()}</span>
{/if}
</button>
</div>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
/** Optional muted line under the title (e.g. "Last updated ..."). */
subtitle?: string;
children: Snippet;
}
let { title, subtitle, children }: Props = $props();
</script>
<svelte:head>
<title>{title} · Drizz.li</title>
</svelte:head>
<article class="mx-auto max-w-3xl py-2 lg:py-4">
<h1 class="text-2xl font-bold tracking-tight lg:text-3xl">{title}</h1>
{#if subtitle}
<p class="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>
{/if}
<div class="prose-body mt-6">
{@render children()}
</div>
</article>
<style>
/* Lightweight typography for long-form pages (about / legal), themed with the
app tokens so it reads correctly in light and dark. */
.prose-body {
line-height: 1.65;
font-size: 0.95rem;
}
.prose-body :global(h2) {
margin: 2rem 0 0.6rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.prose-body :global(h3) {
margin: 1.4rem 0 0.4rem;
font-size: 1rem;
font-weight: 600;
}
.prose-body :global(p) {
margin: 0.6rem 0;
}
.prose-body :global(ul) {
margin: 0.6rem 0;
padding-left: 1.4rem;
}
.prose-body :global(li) {
margin: 0.25rem 0;
}
.prose-body :global(a) {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-body :global(strong) {
font-weight: 600;
}
.prose-body :global(address) {
font-style: normal;
margin: 0.6rem 0;
padding: 0.8rem 1rem;
border: 1px solid var(--border);
border-radius: 0.625rem;
background: var(--card);
}
.prose-body :global(hr) {
margin: 2rem 0;
border: 0;
border-top: 1px solid var(--border);
}
</style>
+1 -1
View File
@@ -6,7 +6,7 @@
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
@@ -22,6 +22,26 @@
children: Snippet;
showCloseButton?: boolean;
} = $props();
// Keep the (fixed, layout-viewport-centered) dialog centered within the
// VISUAL viewport, so the mobile keyboard shifts it up instead of covering it.
let kbShift = $state(0);
$effect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const visualCenter = vv.offsetTop + vv.height / 2;
// negative when the keyboard eats the bottom → moves the dialog up
kbShift = Math.min(0, visualCenter - window.innerHeight / 2);
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
});
</script>
<DialogPortal {...portalProps}>
@@ -33,6 +53,7 @@
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
style="margin-top:{kbShift}px;transition:margin-top 0.18s ease"
{...restProps}
>
{@render children?.()}
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
import * as m from '$lib/paraglide/messages';
// each group maps a stored unit key to its selectable options
const UNIT_GROUPS: {
key: keyof UnitPrefs;
label: () => string;
options: { value: string; label: string }[];
}[] = [
{
key: 'temperature_unit',
label: m.unit_temperature,
options: [
{ value: 'celsius', label: '°C' },
{ value: 'fahrenheit', label: '°F' }
]
},
{
key: 'wind_speed_unit',
label: m.unit_wind_speed,
options: [
{ value: 'kmh', label: 'km/h' },
{ value: 'ms', label: 'm/s' },
{ value: 'mph', label: 'mph' },
{ value: 'kn', label: 'kn' }
]
},
{
key: 'precipitation_unit',
label: m.unit_precipitation,
options: [
{ value: 'mm', label: 'mm' },
{ value: 'inch', label: 'inch' }
]
}
];
function setUnit(key: keyof UnitPrefs, value: string) {
storedUnits.update((u) => ({ ...u, [key]: value }));
}
</script>
<div class="flex flex-col gap-4">
{#each UNIT_GROUPS as group (group.key)}
<div>
<span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
{group.label()}
</span>
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each group.options as opt (opt.value)}
{@const active = $storedUnits[group.key] === opt.value}
<button
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={active}
onclick={() => setUnit(group.key, opt.value)}
>
{opt.label}
</button>
{/each}
</div>
</div>
{/each}
</div>
+6 -68
View File
@@ -1,52 +1,15 @@
<script lang="ts">
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
import * as Popover from '$lib/components/ui/popover';
import UnitOptions from '$lib/components/unit-options.svelte';
// each group maps a stored unit key to its selectable options
const UNIT_GROUPS: {
key: keyof UnitPrefs;
label: string;
options: { value: string; label: string }[];
}[] = [
{
key: 'temperature_unit',
label: 'Temperature',
options: [
{ value: 'celsius', label: '°C' },
{ value: 'fahrenheit', label: '°F' }
]
},
{
key: 'wind_speed_unit',
label: 'Wind speed',
options: [
{ value: 'kmh', label: 'km/h' },
{ value: 'ms', label: 'm/s' },
{ value: 'mph', label: 'mph' },
{ value: 'kn', label: 'kn' }
]
},
{
key: 'precipitation_unit',
label: 'Precipitation',
options: [
{ value: 'mm', label: 'mm' },
{ value: 'inch', label: 'inch' }
]
}
];
function setUnit(key: keyof UnitPrefs, value: string) {
storedUnits.update((u) => ({ ...u, [key]: value }));
}
import * as m from '$lib/paraglide/messages';
</script>
<Popover.Root>
<Popover.Trigger
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-2.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label="Choose measurement units"
title="Units"
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label={m.units_aria()}
title={m.units_title()}
>
<!-- gauge icon -->
<svg
@@ -63,33 +26,8 @@
/>
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
</svg>
<span class="hidden text-xs font-semibold sm:inline">Units</span>
</Popover.Trigger>
<Popover.Content align="end" class="w-64 border-border">
<div class="flex flex-col gap-4">
{#each UNIT_GROUPS as group (group.key)}
<div>
<span
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
>
{group.label}
</span>
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each group.options as opt (opt.value)}
{@const active = $storedUnits[group.key] === opt.value}
<button
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={active}
onclick={() => setUnit(group.key, opt.value)}
>
{opt.label}
</button>
{/each}
</div>
</div>
{/each}
</div>
<UnitOptions />
</Popover.Content>
</Popover.Root>
+81
View File
@@ -0,0 +1,81 @@
/**
* Locale-aware routing helpers.
*
* SvelteKit's `resolve()` returns the language-neutral path a route lives at;
* every link has to go through `localizeHref()` on top of that, or clicking it
* would drop the visitor back into the base locale (the reroute hook in
* `src/hooks.ts` strips the prefix again on the way in).
*/
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { type Locale, deLocalizeHref, locales, localizeHref } from '$lib/paraglide/runtime';
import type {
PathnameWithSearchOrHash,
RouteId,
RouteIdWithSearchOrHash,
RouteParams
} from '$app/types';
// Mirrors SvelteKit's own (non-exported) argument type for `resolve`, so a
// route id still demands exactly the params that route declares.
type StripSearchOrHash<T extends string> = T extends `${infer P}?${string}`
? P
: T extends `${infer P}#${string}`
? P
: T;
type ResolveArgs<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash> = T extends RouteId
? RouteParams<T> extends Record<string, never>
? [route: T]
: [route: T, params: RouteParams<T>]
: StripSearchOrHash<T> extends infer U extends RouteId
? RouteParams<U> extends Record<string, never>
? [route: T]
: [route: T, params: RouteParams<U>]
: [route: T];
/**
* `resolve()` for the current locale. Mirrors SvelteKit's own signature, so
* route ids and their params keep being type-checked.
*/
export function href<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
...args: ResolveArgs<T>
): string {
return localizeHref(resolve(...args));
}
/** Same as `href`, but forced into a specific locale. */
export function hrefIn<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
locale: Locale,
...args: ResolveArgs<T>
): string {
return localizeHref(resolve(...args), { locale });
}
/** `goto()` that keeps the visitor in their language. */
export function gotoLocalized<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
...args: ResolveArgs<T>
) {
return goto(href(...args));
}
/**
* The language-neutral path for a URL that still carries its locale prefix -
* what "which page am I on?" checks have to compare against.
*/
export function routePath(pathname: string): string {
return deLocalizeHref(pathname);
}
/** Display names for the language switcher, in the language itself. */
export const LOCALE_LABELS: Record<Locale, string> = {
en: 'English',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
it: 'Italiano'
};
export const LOCALE_LIST = locales;
+156
View File
@@ -0,0 +1,156 @@
/**
* Picks a handful of cities around a location, for cross-referencing a forecast
* against places you already have a feel for.
*
* Open-Meteo's geocoding API can only search by name - there is no radius or
* reverse lookup - so the candidates come from a static GeoNames extract, cut
* into 10x10 degree tiles (see scripts/build-cities.mjs). Looking somewhere up
* costs one small tile fetch, which the browser then caches.
*/
import { base } from '$app/paths';
/** [geonames id, name, country code, latitude, longitude, population/1000] */
type CityRow = [number, string, string, number, number, number];
export interface NearbyCity {
id: number;
name: string;
countryCode: string;
latitude: number;
longitude: number;
population: number;
distanceKm: number;
}
const EARTH_RADIUS_KM = 6371;
const TILE_DEGREES = 10;
export function distanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = Math.PI / 180;
const dLat = (lat2 - lat1) * toRad;
const dLon = (lon2 - lon1) * toRad;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
function tileKey(latitude: number, longitude: number): string {
const lat = Math.min(89.999, Math.max(-90, latitude));
const lon = ((((longitude + 180) % 360) + 360) % 360) - 180;
return `${Math.floor((lat + 90) / TILE_DEGREES)}_${Math.floor((lon + 180) / TILE_DEGREES)}`;
}
const tileCache = new Map<string, Promise<CityRow[]>>();
let indexPromise: Promise<Set<string>> | null = null;
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${base}/data/cities/${path}`);
if (!res.ok) throw new Error(`${path}: ${res.status}`);
return res.json() as Promise<T>;
}
/** Tiles exist only where there are cities, so the index turns an empty ocean
* tile into a no-op instead of a 404. */
function loadIndex(): Promise<Set<string>> {
indexPromise ??= fetchJson<string[]>('index.json')
.then((keys) => new Set(keys))
.catch((err) => {
indexPromise = null;
throw err;
});
return indexPromise;
}
async function loadTile(key: string): Promise<CityRow[]> {
if (!(await loadIndex()).has(key)) return [];
let tile = tileCache.get(key);
if (!tile) {
tile = fetchJson<CityRow[]>(`${key}.json`).catch((err) => {
// a failed fetch must not poison the cache: the next visit retries
tileCache.delete(key);
throw err;
});
tileCache.set(key, tile);
}
return tile;
}
/**
* Widening rings. Within 400 km the tile holds towns down to 20k; past that
* only cities above 300k, which is exactly the bias a wide search wants - if
* nothing is nearby, the answer should be a place people have heard of.
*/
const SEARCH_RADII_KM = [200, 400, 1500];
/**
* How far out still counts as "the place you are already looking at". A village
* ends where its fields start; London's own boroughs sit 20 km from its centre
* and share its weather, so the radius grows with the size of the location.
*/
function samePlaceKm(population: number): number {
return 10 + 15 * Math.min(1, population / 5_000_000);
}
/**
* Returns up to `count` cities around the given point, ordered by distance.
*
* Candidates are ranked by population damped by distance, so a town up the
* valley can outrank a metropolis three hours away, and picks have to keep
* their distance from each other - otherwise a place like New York fills the
* list with its own boroughs, and a big city fills it with commuter suburbs
* that share its weather anyway.
*/
export async function findNearbyCities(
latitude: number,
longitude: number,
count = 10,
population = 0
): Promise<NearbyCity[]> {
const rows = await loadTile(tileKey(latitude, longitude));
if (rows.length === 0) return [];
const ownFootprintKm = samePlaceKm(population);
let best: NearbyCity[] = [];
for (const radius of SEARCH_RADII_KM) {
const halfWeightKm = radius / 2;
// far-apart picks in a wide search, tight ones when everything is close
const minSeparationKm = Math.max(25, radius / 20);
const scored = rows
.map(([id, name, countryCode, lat, lon, popK]) => {
const dist = distanceKm(latitude, longitude, lat, lon);
return {
city: {
id,
name,
countryCode,
latitude: lat,
longitude: lon,
population: popK * 1000,
distanceKm: dist
},
score: popK / (1 + (dist / halfWeightKm) ** 2)
};
})
.filter(({ city }) => city.distanceKm >= ownFootprintKm && city.distanceKm <= radius)
.sort((a, b) => b.score - a.score);
const picked: NearbyCity[] = [];
for (const { city } of scored) {
if (picked.length === count) break;
const tooClose = picked.some(
(p) => distanceKm(p.latitude, p.longitude, city.latitude, city.longitude) < minSeparationKm
);
if (!tooClose) picked.push(city);
}
if (picked.length >= count) return picked.sort((a, b) => a.distanceKm - b.distanceKm);
if (picked.length > best.length) best = picked;
}
return best.sort((a, b) => a.distanceKm - b.distanceKm);
}
+699 -154
View File
@@ -10,10 +10,12 @@
* - 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';
import { type DaylightBand, buildDaylightBands } from '$lib/charts/bands';
import * as m from '$lib/paraglide/messages';
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
@@ -22,6 +24,8 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
const SEASONAL_URL = 'https://seasonal-api.open-meteo.com/v1/seasonal';
// ─── Core Helpers ───────────────────────────────────────────────────────────────
@@ -206,6 +210,17 @@ export interface WeekDailyData {
windspeed_10m_max: number[];
windgusts_10m_max: number[];
winddirection_10m_dominant: number[];
// Only the live forecast carries these; the archive-backed views reuse this
// shape without them, so they stay optional.
/** Seconds between sunrise and sunset. */
daylight_duration?: number[];
uv_index_max?: number[];
precipitation_probability_max?: number[];
/** Unix seconds; 0 on the days the moon doesn't rise / set at all. */
moonrise?: number[];
moonset?: number[];
/** 0 and 1 are new moon, 0.5 is full moon. */
moon_phase?: number[];
}
export interface WeekForecastResult {
@@ -227,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[]>;
}
@@ -272,6 +290,65 @@ export interface EnsembleForecastResult {
hourlyUnitsFlat: Record<string, string>;
}
// ─── Error Humanizing ───────────────────────────────────────────────────────────
export interface FriendlyWeatherError {
/** Short, plain-language headline. */
title: string;
/** What the user can actually do about it. */
hint?: string;
/** The raw underlying message, for a collapsed "technical details" block. */
detail?: string;
}
/**
* Turns a fetch/API error into something a person can act on. The raw message
* (often API-speak like "No data is available for this location") is kept as
* `detail` so it can be shown collapsed.
*/
export function humanizeWeatherError(err: unknown): FriendlyWeatherError {
const raw = err instanceof Error ? err.message : String(err);
const msg = raw.toLowerCase();
if (
err instanceof TypeError ||
msg.includes('failed to fetch') ||
msg.includes('networkerror') ||
msg.includes('load failed') ||
msg.includes('network request failed')
) {
return {
title: m.err_network_title(),
hint: m.err_network_hint(),
detail: raw
};
}
if (
msg.includes('no data is available') ||
msg.includes('not available for this location') ||
msg.includes('out of allowed range') ||
msg.includes('coordinates')
) {
return {
title: m.err_nodata_title(),
hint: m.err_nodata_hint(),
detail: raw
};
}
if (msg.includes('invalid') || msg.includes('cannot be') || msg.includes('bad request')) {
return {
title: m.err_rejected_title(),
hint: m.err_rejected_hint(),
detail: raw
};
}
return {
title: m.err_generic_title(),
hint: m.err_generic_hint(),
detail: raw
};
}
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
// Fallback set when the caller does not specify which hourly variables it
@@ -300,9 +377,49 @@ const WEEK_DAILY_VARS = [
'precipitation_sum',
'wind_speed_10m_max',
'wind_gusts_10m_max',
'wind_direction_10m_dominant'
'wind_direction_10m_dominant',
'daylight_duration',
'uv_index_max',
'precipitation_probability_max',
'moonrise',
'moonset',
'moon_phase'
] as const;
/**
* Assembles a WeekHourlyData structure from a name→values map, so variables that
* were not requested resolve to empty arrays. Shared by the week and historical
* fetchers (both return the same hourly shape, which the meteograms and hourly
* table consume).
*/
function weekHourlyFromByName(byName: Record<string, number[]>): WeekHourlyData {
const g = (name: string): number[] => byName[name] ?? [];
return {
temperature_2m: g('temperature_2m'),
precipitation: g('precipitation'),
precipitation_probability: g('precipitation_probability'),
weather_code: g('weather_code'),
windspeed_10m: g('wind_speed_10m'),
winddirection_10m: g('wind_direction_10m'),
cloud_cover: g('cloud_cover'),
relative_humidity_2m: g('relative_humidity_2m'),
apparent_temperature: g('apparent_temperature'),
dew_point_2m: g('dew_point_2m'),
wind_gusts_10m: g('wind_gusts_10m'),
pressure_msl: g('pressure_msl'),
surface_pressure: g('surface_pressure'),
rain: g('rain'),
showers: g('showers'),
snowfall: g('snowfall'),
cloud_cover_low: g('cloud_cover_low'),
cloud_cover_mid: g('cloud_cover_mid'),
cloud_cover_high: g('cloud_cover_high'),
uv_index: g('uv_index'),
visibility: g('visibility'),
cape: g('cape')
};
}
/**
* Fetches the 7-day (week) weather forecast for a single location and model.
* Returns typed hourly and daily data structures.
@@ -359,32 +476,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
const variable = hourlyBlock.variables(i);
byName[name] = variable ? getValues(variable) : [];
});
const g = (name: string): number[] => byName[name] ?? [];
const hourly: WeekHourlyData = {
temperature_2m: g('temperature_2m'),
precipitation: g('precipitation'),
precipitation_probability: g('precipitation_probability'),
weather_code: g('weather_code'),
windspeed_10m: g('wind_speed_10m'),
winddirection_10m: g('wind_direction_10m'),
cloud_cover: g('cloud_cover'),
relative_humidity_2m: g('relative_humidity_2m'),
apparent_temperature: g('apparent_temperature'),
dew_point_2m: g('dew_point_2m'),
wind_gusts_10m: g('wind_gusts_10m'),
pressure_msl: g('pressure_msl'),
surface_pressure: g('surface_pressure'),
rain: g('rain'),
showers: g('showers'),
snowfall: g('snowfall'),
cloud_cover_low: g('cloud_cover_low'),
cloud_cover_mid: g('cloud_cover_mid'),
cloud_cover_high: g('cloud_cover_high'),
uv_index: g('uv_index'),
visibility: g('visibility'),
cape: g('cape')
};
const hourly = weekHourlyFromByName(byName);
// Daily: variables are in the same order as WEEK_DAILY_VARS
const dailyDates = getDates(dailyBlock);
@@ -392,6 +485,17 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
const sunriseVar = dailyBlock.variables(3)!;
const sunsetVar = dailyBlock.variables(4)!;
// Optional tail variables: a model that doesn't carry them yields fewer
// entries, so read them defensively instead of asserting.
const dailyAt = (i: number): number[] => {
const v = dailyBlock.variables(i);
return v ? getValues(v) : [];
};
const dailyInt64At = (i: number): number[] => {
const v = dailyBlock.variables(i);
return v ? getInt64Values(v) : [];
};
const daily: WeekDailyData = {
weather_code: getValues(dailyBlock.variables(0)!),
temperature_2m_max: getValues(dailyBlock.variables(1)!),
@@ -402,7 +506,13 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
precipitation_sum: getValues(dailyBlock.variables(6)!),
windspeed_10m_max: getValues(dailyBlock.variables(7)!),
windgusts_10m_max: getValues(dailyBlock.variables(8)!),
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!),
daylight_duration: dailyAt(10),
uv_index_max: dailyAt(11),
precipitation_probability_max: dailyAt(12),
moonrise: dailyInt64At(13),
moonset: dailyInt64At(14),
moon_phase: dailyAt(15)
};
const daylightBands = buildDaylightBands(daily.sunrise, daily.sunset);
@@ -429,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),
@@ -443,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
@@ -480,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: {}
};
@@ -502,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
@@ -680,126 +811,540 @@ export async function fetchEnsembleForecast(
};
}
// ─── Model Enum Mapping ─────────────────────────────────────────────────────────
// ─── Historical (Archive) Types ─────────────────────────────────────────────
export interface HistoricalDailyData {
weather_code: number[];
temperature_2m_max: number[];
temperature_2m_min: number[];
temperature_2m_mean: number[];
apparent_temperature_max: number[];
apparent_temperature_min: number[];
sunrise: number[];
sunset: number[];
sunshine_duration: number[];
precipitation_sum: number[];
rain_sum: number[];
snowfall_sum: number[];
precipitation_hours: number[];
windspeed_10m_max: number[];
windgusts_10m_max: number[];
winddirection_10m_dominant: number[];
}
export interface HistoricalForecastParams extends WeatherLocation, WeatherUnitParams {
/** Inclusive range, YYYY-MM-DD (location-local dates). */
start_date: string;
end_date: string;
/** Hourly API variables to request; defaults to the core week set. */
hourlyVariables?: string[];
/** Reanalysis to read from; omitted lets the API pick. */
model?: string;
}
export interface HistoricalForecastResult {
hourly: WeekHourlyData;
daily: HistoricalDailyData;
utcOffsetSeconds: number;
timezone: string;
hourlyTimestamps: number[];
hourlyDates: Date[];
dailyDates: Date[];
daylightBands: DaylightBand[];
}
// Requested in this exact order; the daily block returns variables positionally.
const HISTORICAL_DAILY_VARS = [
'weather_code',
'temperature_2m_max',
'temperature_2m_min',
'temperature_2m_mean',
'apparent_temperature_max',
'apparent_temperature_min',
'sunrise',
'sunset',
'sunshine_duration',
'precipitation_sum',
'rain_sum',
'snowfall_sum',
'precipitation_hours',
'wind_speed_10m_max',
'wind_gusts_10m_max',
'wind_direction_10m_dominant'
] as const;
// ─── Historical (Archive) Fetch ─────────────────────────────────────────────
/**
* Maps the SDK Model enum integer to a string model name.
* This table must stay in sync with the @openmeteo/sdk Model enum.
* Fetches reanalysis (ERA5) weather for a past date range from the Open-Meteo
* archive API. Returns the same hourly shape as the week forecast (so the
* existing meteograms and hourly table render it unchanged) plus a richer daily
* block for the climate/statistics view.
*/
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'
export async function fetchHistoricalWeather(
params: HistoricalForecastParams
): Promise<HistoricalForecastResult> {
const hourlyVars =
params.hourlyVariables && params.hourlyVariables.length > 0
? [...new Set(params.hourlyVariables)]
: [...WEEK_HOURLY_VARS];
const apiParams: Record<string, string | number | undefined> = {
latitude: params.latitude,
longitude: params.longitude,
start_date: params.start_date,
end_date: params.end_date,
hourly: hourlyVars.join(','),
daily: HISTORICAL_DAILY_VARS.join(','),
temperature_unit: params.temperature_unit ?? 'celsius',
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
precipitation_unit: params.precipitation_unit ?? 'mm',
timezone: params.timezone,
models: params.model && params.model !== 'best_match' ? params.model : undefined
};
const cleanParams: Record<string, string> = {};
for (const [key, value] of Object.entries(apiParams)) {
if (value !== undefined) cleanParams[key] = String(value);
}
const responses = await fetchWeatherApi(ARCHIVE_URL, cleanParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
const hourlyBlock = response.hourly()!;
const dailyBlock = response.daily()!;
const hourlyTimestamps = getTimestamps(hourlyBlock);
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
const byName: Record<string, number[]> = {};
hourlyVars.forEach((name, i) => {
const variable = hourlyBlock.variables(i);
byName[name] = variable ? getValues(variable) : [];
});
const hourly = weekHourlyFromByName(byName);
// Daily variables come back in HISTORICAL_DAILY_VARS order.
const dailyDates = getDates(dailyBlock);
const d = (i: number): number[] => {
const v = dailyBlock.variables(i);
return v ? getValues(v) : [];
};
const sunrise = getInt64Values(dailyBlock.variables(6)!);
const sunset = getInt64Values(dailyBlock.variables(7)!);
const daily: HistoricalDailyData = {
weather_code: d(0),
temperature_2m_max: d(1),
temperature_2m_min: d(2),
temperature_2m_mean: d(3),
apparent_temperature_max: d(4),
apparent_temperature_min: d(5),
sunrise,
sunset,
sunshine_duration: d(8),
precipitation_sum: d(9),
rain_sum: d(10),
snowfall_sum: d(11),
precipitation_hours: d(12),
windspeed_10m_max: d(13),
windgusts_10m_max: d(14),
winddirection_10m_dominant: d(15)
};
const daylightBands = buildDaylightBands(sunrise, sunset);
return {
hourly,
daily,
utcOffsetSeconds,
timezone,
hourlyTimestamps,
hourlyDates,
dailyDates,
daylightBands
};
return modelMap[modelEnum] ?? `model_${modelEnum}`;
}
// ─── Climate Normals ────────────────────────────────────────────────────────
export interface ClimateNormals {
/** Indexed by day-of-year ordinal 1..366 (index 0 unused); NaN where no data. */
tmax: number[];
tmin: number[];
tmean: number[];
/** Mean daily precipitation (per calendar day). */
precip: number[];
baseStart: string;
baseEnd: string;
temperature_unit: string;
precipitation_unit: string;
}
export interface ClimateNormalsParams extends WeatherLocation, WeatherUnitParams {
/** Baseline period; defaults to the 1991-2020 WMO normal period. */
baseStart?: string;
baseEnd?: string;
}
// Days before the first of each month in a leap reference year, so that a
// (month, day) pair maps to a stable 1..366 ordinal regardless of leap years.
const CUM_DAYS_LEAP = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
/** Day-of-year ordinal (1..366) from month (1-12) and day-of-month (1-31). */
export function monthDayToOrdinal(month: number, day: number): number {
const m = Math.min(12, Math.max(1, Math.round(month)));
return CUM_DAYS_LEAP[m - 1] + day;
}
/**
* Computes daily climate normals for a location by averaging a multi-decade
* archive across years, per day-of-year, with a ±7-day smoothing window so the
* curve is stable. One archive request; used for the "vs normal" comparison.
*/
export async function fetchClimateNormals(params: ClimateNormalsParams): Promise<ClimateNormals> {
const baseStart = params.baseStart ?? '1991-01-01';
const baseEnd = params.baseEnd ?? '2020-12-31';
// UTC keeps the day-of-year bucketing exact (no offset spill across midnight);
// timezone is irrelevant to a per-calendar-day normal.
const apiParams: Record<string, string> = {
latitude: String(params.latitude),
longitude: String(params.longitude),
start_date: baseStart,
end_date: baseEnd,
daily: 'temperature_2m_max,temperature_2m_min,temperature_2m_mean,precipitation_sum',
temperature_unit: params.temperature_unit ?? 'celsius',
precipitation_unit: params.precipitation_unit ?? 'mm',
timezone: 'UTC'
};
const responses = await fetchWeatherApi(ARCHIVE_URL, apiParams);
const response = responses[0];
const dailyBlock = response.daily()!;
const dates = getDates(dailyBlock);
const tmaxV = getValues(dailyBlock.variables(0)!);
const tminV = getValues(dailyBlock.variables(1)!);
const tmeanV = getValues(dailyBlock.variables(2)!);
const precipV = getValues(dailyBlock.variables(3)!);
const N = 367; // ordinals 1..366
const mk = () => ({ sum: new Array<number>(N).fill(0), cnt: new Array<number>(N).fill(0) });
const acc = { tmax: mk(), tmin: mk(), tmean: mk(), precip: mk() };
const add = (bucket: { sum: number[]; cnt: number[] }, ord: number, val: number) => {
if (Number.isFinite(val)) {
bucket.sum[ord] += val;
bucket.cnt[ord] += 1;
}
};
for (let i = 0; i < dates.length; i++) {
const dt = dates[i];
const ord = monthDayToOrdinal(dt.getUTCMonth() + 1, dt.getUTCDate());
add(acc.tmax, ord, tmaxV[i]);
add(acc.tmin, ord, tminV[i]);
add(acc.tmean, ord, tmeanV[i]);
add(acc.precip, ord, precipV[i]);
}
const mean = (bucket: { sum: number[]; cnt: number[] }): number[] =>
bucket.sum.map((s, i) => (bucket.cnt[i] > 0 ? s / bucket.cnt[i] : NaN));
// Circular ±window smoothing across the 366 ordinals (skips empty days).
const smooth = (arr: number[], window = 7): number[] => {
const out = new Array<number>(N).fill(NaN);
for (let o = 1; o <= 366; o++) {
let s = 0;
let c = 0;
for (let k = -window; k <= window; k++) {
const idx = ((o - 1 + k + 366) % 366) + 1;
const v = arr[idx];
if (Number.isFinite(v)) {
s += v;
c++;
}
}
out[o] = c > 0 ? s / c : NaN;
}
return out;
};
return {
tmax: smooth(mean(acc.tmax)),
tmin: smooth(mean(acc.tmin)),
tmean: smooth(mean(acc.tmean)),
precip: smooth(mean(acc.precip)),
baseStart,
baseEnd,
temperature_unit: params.temperature_unit ?? 'celsius',
precipitation_unit: params.precipitation_unit ?? 'mm'
};
}
// ─── Seasonal (Long-Range) Types ────────────────────────────────────────────
/**
* One daily variable of the seasonal ensemble: every member plus the spread
* statistics the outlook renders (percentile band, mean, extremes).
*/
export interface SeasonalVariableData {
/** Raw members, `members[m][t]`. */
members: number[][];
mean: number[];
min: number[];
max: number[];
p25: number[];
p75: number[];
unit: string;
}
export interface SeasonalForecastParams extends WeatherLocation, WeatherUnitParams {
/** Daily API variables to request; defaults to SEASONAL_DAILY_VARS. */
dailyVariables?: string[];
/** Lead time in days; the API allows at most 216. */
forecast_days?: number;
/** Seasonal model; omitted lets the API pick. */
model?: string;
}
export interface SeasonalForecastResult {
variables: Record<string, SeasonalVariableData>;
/** Milliseconds, one entry per day (already trimmed to the model's horizon). */
timestamps: number[];
/**
* Local wall time (local midnight) expressed as a UTC instant - read these
* with the UTC getters, never with the location's IANA zone. The seasonal API
* keeps ONE offset for the whole series, so a half-year range that crosses a
* DST change would otherwise land two days on the same local date.
*/
dailyDates: Date[];
/** `YYYY-MM-DD` local calendar date per day, matching the API's own labels. */
dateKeys: string[];
memberCount: number;
utcOffsetSeconds: number;
timezone: string;
}
/** The API caps the lead time here; the model itself usually stops earlier. */
export const SEASONAL_MAX_DAYS = 216;
/** Requested in this order; the daily block returns variables positionally. */
export const SEASONAL_DAILY_VARS = [
'temperature_2m_max',
'temperature_2m_min',
'temperature_2m_mean',
'precipitation_sum',
'wind_speed_10m_mean',
'cloud_cover_mean'
] as const;
// ─── Seasonal (Long-Range) Fetch ────────────────────────────────────────────
/** Linear-interpolated percentile over an already ascending array. */
function percentileSorted(sorted: number[], p: number): number {
if (sorted.length === 0) return NaN;
if (sorted.length === 1) return sorted[0];
const pos = (sorted.length - 1) * p;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}
/**
* Fetches the seasonal (multi-month) ensemble outlook from Open-Meteo's
* seasonal API. Unlike the medium-range ensemble this is daily data: each
* requested variable comes back once per member, so the members are collapsed
* into the spread statistics the outlook page plots.
*
* The requested lead time is only an upper bound - the model's own horizon is
* shorter, and every day past it comes back empty. Those trailing days are
* trimmed here so callers never plot a flat-lined tail.
*/
export async function fetchSeasonalForecast(
params: SeasonalForecastParams
): Promise<SeasonalForecastResult> {
const dailyVars =
params.dailyVariables && params.dailyVariables.length > 0
? [...new Set(params.dailyVariables)]
: [...SEASONAL_DAILY_VARS];
const apiParams: Record<string, string | number | undefined> = {
latitude: params.latitude,
longitude: params.longitude,
daily: dailyVars.join(','),
forecast_days: Math.min(params.forecast_days ?? SEASONAL_MAX_DAYS, SEASONAL_MAX_DAYS),
temperature_unit: params.temperature_unit ?? 'celsius',
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
precipitation_unit: params.precipitation_unit ?? 'mm',
timezone: params.timezone,
models: params.model && params.model !== 'best_match' ? params.model : undefined
};
const cleanParams: Record<string, string> = {};
for (const [key, value] of Object.entries(apiParams)) {
if (value !== undefined) cleanParams[key] = String(value);
}
const responses = await fetchWeatherApi(SEASONAL_URL, cleanParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
const dailyBlock = response.daily()!;
const allTimestamps = getTimestamps(dailyBlock);
const timeLength = allTimestamps.length;
// Members are laid out like the ensemble API: var0_member0 … var0_memberM-1,
// var1_member0 …, so the count follows from the totals instead of being
// hard-coded (it differs per seasonal model).
const totalVariables = dailyBlock.variablesLength();
const memberCount = dailyVars.length > 0 ? Math.floor(totalVariables / dailyVars.length) : 0;
const variables: Record<string, SeasonalVariableData> = {};
for (let vi = 0; vi < dailyVars.length; vi++) {
const members: number[][] = [];
let unitStr = '';
for (let mi = 0; mi < memberCount; mi++) {
const variable = dailyBlock.variables(vi * memberCount + mi);
if (!variable) continue;
members.push(getValues(variable));
if (mi === 0) unitStr = unitToDisplayString(variable.unit());
}
const mean = new Array<number>(timeLength).fill(NaN);
const min = new Array<number>(timeLength).fill(NaN);
const max = new Array<number>(timeLength).fill(NaN);
const p25 = new Array<number>(timeLength).fill(NaN);
const p75 = new Array<number>(timeLength).fill(NaN);
for (let t = 0; t < timeLength; t++) {
const values: number[] = [];
for (const memberValues of members) {
const val = memberValues[t];
if (val != null && Number.isFinite(val)) values.push(val);
}
if (values.length === 0) continue;
values.sort((a, b) => a - b);
mean[t] = values.reduce((a, b) => a + b, 0) / values.length;
min[t] = values[0];
max[t] = values[values.length - 1];
p25[t] = percentileSorted(values, 0.25);
p75[t] = percentileSorted(values, 0.75);
}
variables[dailyVars[vi]] = { members, mean, min, max, p25, p75, unit: unitStr };
}
// Past the model's horizon every member is empty (or padded to a constant
// zero); cut the axis at the last day that carries real spread.
const sentinel = variables[dailyVars[0]];
let validLength = timeLength;
if (sentinel) {
let last = 0;
for (let t = 0; t < timeLength; t++) {
const hasSpread = !(sentinel.min[t] === 0 && sentinel.max[t] === 0);
if (Number.isFinite(sentinel.mean[t]) && hasSpread) last = t + 1;
}
validLength = last || timeLength;
}
if (validLength < timeLength) {
for (const data of Object.values(variables)) {
data.members = data.members.map((m) => m.slice(0, validLength));
data.mean = data.mean.slice(0, validLength);
data.min = data.min.slice(0, validLength);
data.max = data.max.slice(0, validLength);
data.p25 = data.p25.slice(0, validLength);
data.p75 = data.p75.slice(0, validLength);
}
}
const timestamps = allTimestamps.slice(0, validLength);
// Shifted by the response's single offset (not the IANA zone) so each day
// carries the exact local date the API labelled it with.
const dailyDates = timestamps.map((t) => new Date(t + utcOffsetSeconds * 1000));
return {
variables,
timestamps,
dailyDates,
dateKeys: dailyDates.map((d) => d.toISOString().slice(0, 10)),
memberCount,
utcOffsetSeconds,
timezone
};
}
// ─── Nearby cities snapshot ─────────────────────────────────────────────────────
export interface NearbyDaily {
/** local calendar date ("yyyy-MM-dd") -> that day's summary for this city */
byDate: Record<string, { weatherCode: number; max: number; min: number; precipitation: number }>;
}
export interface NearbySnapshotParams extends WeatherUnitParams {
points: { latitude: number; longitude: number }[];
past_days?: number;
forecast_days?: number;
}
/**
* Fetches a daily summary for several locations in one request - the forecast
* API takes comma-separated coordinates and answers with one response per
* point, in order.
*
* Deliberately runs on best_match: the nearby list can reach well past the
* domain of whatever regional model the page is showing, and a row of dashes
* is worse than a row from a model that covers everywhere.
*/
export async function fetchNearbyDaily(
params: NearbySnapshotParams
): Promise<(NearbyDaily | null)[]> {
if (params.points.length === 0) return [];
const apiParams: Record<string, string> = {
latitude: params.points.map((p) => p.latitude).join(','),
longitude: params.points.map((p) => p.longitude).join(','),
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum',
temperature_unit: params.temperature_unit ?? 'celsius',
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
precipitation_unit: params.precipitation_unit ?? 'mm',
past_days: String(params.past_days ?? 3),
forecast_days: String(params.forecast_days ?? 16),
timezone: 'auto'
};
const responses = await fetchWeatherApi(FORECAST_URL, apiParams);
return params.points.map((_, i) => {
const response = responses[i];
const dailyBlock = response?.daily();
if (!dailyBlock) return null;
const utcOffsetSeconds = response.utcOffsetSeconds();
const codes = getValues(dailyBlock.variables(0)!);
const max = getValues(dailyBlock.variables(1)!);
const min = getValues(dailyBlock.variables(2)!);
const precip = getValues(dailyBlock.variables(3)!);
// Same convention as the seasonal fetch: shift by the response's own
// offset, then read the calendar date off the ISO string.
const byDate: NearbyDaily['byDate'] = {};
getTimestamps(dailyBlock).forEach((t, d) => {
const key = new Date(t + utcOffsetSeconds * 1000).toISOString().slice(0, 10);
byDate[key] = {
weatherCode: codes[d],
max: max[d],
min: min[d],
precipitation: precip[d]
};
});
return { byDate };
});
}
+55
View File
@@ -0,0 +1,55 @@
import { writable } from 'svelte/store';
/**
* Whether the current page has enough data on screen to be worth revealing.
*
* The weather pages fetch their forecast *after* the route swap, so a plain
* navigation transition would cross-fade one skeleton into another and then cut
* hard to the real content. The layout holds its transition open on this flag
* instead, so the fade lines up with the page actually being loaded.
*/
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.
*
* The layout owns the "not ready yet" side deliberately: the incoming page's
* effects have not necessarily run at that point (rendering is paused inside a
* view transition), so a page that cleared the flag itself would sometimes be
* announced as ready before it had fetched anything.
*/
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).
*/
export function reportPageReady(isReady: () => boolean): void {
$effect(() => {
if (isReady()) pageContentReady.set(true);
});
}
+44
View File
@@ -86,6 +86,37 @@ export const defaultVariablePrefs: VariablePrefs = {
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
/** Order of the hourly-table rows (visibility is the separate toggle above). */
export const defaultTableRowOrder: string[] = [
'icons',
'temperature',
'feels',
'dew_point',
'wind',
'gusts',
'humidity',
'clouds',
'pressure',
'uv',
'visibility',
'precipitation',
'snowfall'
];
export const storedTableRowOrder = persisted<string[]>('table_row_order_v1', defaultTableRowOrder);
/**
* Normalizes a stored row order against the known rows: drops keys that no
* longer exist and appends new rows (added after the user last saved) at the
* end, so stored orders survive app updates.
*/
export function mergeTableRowOrder(stored: string[]): string[] {
return [
...stored.filter((k) => defaultTableRowOrder.includes(k)),
...defaultTableRowOrder.filter((k) => !stored.includes(k))
];
}
/** Hourly table interval: 3-hourly (default) or 1-hourly. */
export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3);
@@ -107,9 +138,22 @@ export const defaultChartLayout: ChartPanel[] = [
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
/**
* Time range the meteograms open on. 'auto' narrows to three days on a phone,
* where a full week of hours is too dense to read, and shows everything on
* roomier screens.
*/
export type ChartRangePref = 'auto' | 'today' | '3d' | '5d' | 'all';
export const storedChartRange = persisted<ChartRangePref>('chart_range_v1', 'auto');
/** Selected ensemble model for the 14-day spread forecast. */
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
/** Reanalysis used on the historical page, and the seasonal model. */
export const storedArchiveModel = persisted<string>('archive_model', 'best_match');
export const storedSeasonalModel = persisted<string>('seasonal_model', 'best_match');
/** Measurement units, shared across every forecast page and persisted. */
export interface UnitPrefs {
temperature_unit: 'celsius' | 'fahrenheit';
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as m from '$lib/paraglide/messages';
import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { isSupporter, refreshSupporter } from './store';
let open = $state(false);
// Verify once on load so the badge reflects real status site-wide.
onMount(refreshSupporter);
</script>
<button
type="button"
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors {$isSupporter
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
onclick={() => (open = true)}
title={$isSupporter ? m.supporter_active() : m.supporter_support()}
aria-label={$isSupporter ? m.supporter_active() : m.supporter_support()}
>
<SupporterIcon filled={$isSupporter} />
<span class="hidden sm:inline">{m.supporter_badge()}</span>
</button>
<UnlockDialog bind:open />
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as m from '$lib/paraglide/messages';
import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
import { isSupporter, refreshSupporter, supporterState } from './store';
interface Props {
/** Short feature name shown in the locked panel headline. */
feature: string;
children: import('svelte').Snippet;
}
let { feature, children }: Props = $props();
let unlockOpen = $state(false);
const price = getSupporterPrice();
// Re-verify the stored key whenever the gate mounts.
onMount(refreshSupporter);
// Show a brief spinner only when we have no cached answer yet and are checking.
let initialChecking = $derived($supporterState.status === 'checking' && !$isSupporter);
</script>
{#if $isSupporter}
{@render children()}
{:else if initialChecking}
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
<svg class="mr-2 h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
{m.supporter_checking()}
</div>
{:else}
<div
class="mx-auto max-w-xl rounded-2xl border border-border bg-card px-6 py-10 text-center shadow-sm"
>
<div
class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"
>
<SupporterIcon class="h-7 w-7" />
</div>
<!-- the feature name comes from a page subtitle ("météo historique"), which is
lowercase mid-sentence and has to be lifted at the start of this one -->
<h2 class="text-xl font-bold tracking-tight first-letter:uppercase">
{m.supporter_gate_title({ feature })}
</h2>
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
{m.supporter_gate_body({ price })}
</p>
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
{#each SUPPORTER_PERKS() as perk (perk)}
<li class="flex items-start gap-2.5 text-sm">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5" />
</svg>
<span>{perk}</span>
</li>
{/each}
</ul>
<div class="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
<a
href={SIGNUP_URL}
target="_blank"
rel="noopener"
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 sm:w-auto"
>
{m.supporter_become()}
</a>
<button
type="button"
class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg border border-border bg-background px-5 text-sm font-semibold text-foreground transition-colors hover:bg-muted sm:w-auto"
onclick={() => (unlockOpen = true)}
>
{m.supporter_have_key()}
</button>
</div>
{#if $supporterState.status === 'invalid'}
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
{m.supporter_key_expired()}
</p>
{/if}
</div>
{/if}
<UnlockDialog bind:open={unlockOpen} />
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
/**
* 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 hard wall. Filled once someone is supporting,
* outlined as an invitation before that.
*/
interface Props {
filled?: boolean;
class?: string;
}
let { filled = false, class: className = 'h-4 w-4' }: Props = $props();
</script>
{#if filled}
<svg class={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M12 21.1l-1.45-1.32C5.4 15.1 2 12 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.5-3.4 6.6-8.55 11.29L12 21.1z"
/>
</svg>
{:else}
<svg
class={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M12 21.1l-1.45-1.32C5.4 15.1 2 12 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.5-3.4 6.6-8.55 11.29L12 21.1z"
/>
</svg>
{/if}
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import { formatZoned } from '$lib/utils/date';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages';
import { SIGNUP_URL, getSupporterPrice } from './config';
import { clearLicense, isSupporter, storedLicenseKey, supporterState, verifyKey } from './store';
interface Props {
open?: boolean;
}
let { open = $bindable(false) }: Props = $props();
const price = getSupporterPrice();
// Prefill with the stored key so an existing subscriber sees their key.
let keyInput = $state('');
let submitting = $state(false);
let localError = $state<string | null>(null);
$effect(() => {
if (open) {
keyInput = $storedLicenseKey;
localError = null;
}
});
async function submit(e: SubmitEvent) {
e.preventDefault();
if (submitting) return;
submitting = true;
localError = null;
const result = await verifyKey(keyInput);
submitting = false;
if (result.valid) {
// close shortly so the success state is visible for a beat
setTimeout(() => (open = false), 700);
} else {
localError = result.error ?? m.supporter_key_invalid();
}
}
function removeKey() {
clearLicense();
keyInput = '';
}
let expiresLabel = $derived.by(() => {
const exp = $supporterState.expires;
if ($supporterState.status !== 'valid') return null;
if (!exp) return m.supporter_lifetime();
return m.supporter_active_until({ date: formatZoned(new Date(exp), 'UTC', 'd LLL yyyy') });
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>{m.supporter_dialog_title()}</Dialog.Title>
<Dialog.Description>
{m.supporter_dialog_desc()}
</Dialog.Description>
</Dialog.Header>
{#if $isSupporter && $supporterState.status === 'valid'}
<div
class="flex items-start gap-3 rounded-lg border border-emerald-300/60 bg-emerald-50 px-3.5 py-3 text-sm text-emerald-800 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-200"
>
<svg
class="mt-0.5 h-5 w-5 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="9" />
</svg>
<div>
<p class="font-semibold">{m.supporter_extras_active()}</p>
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
</div>
</div>
{/if}
<form onsubmit={submit} class="grid gap-3">
<div class="grid gap-1.5">
<Label for="license-key">{m.supporter_access_key()}</Label>
<Input
id="license-key"
bind:value={keyInput}
placeholder="DRZ-XXXX-XXXX-XXXX"
autocomplete="off"
spellcheck={false}
class="font-mono tracking-wide"
/>
</div>
{#if localError}
<p class="text-sm text-destructive">{localError}</p>
{:else if $supporterState.status === 'error'}
<p class="text-sm text-destructive">
{m.supporter_server_unreachable()}
</p>
{/if}
<Button type="submit" disabled={submitting || !keyInput.trim()}>
{#if submitting}{m.supporter_verifying()}{:else}{m.supporter_unlock_button()}{/if}
</Button>
</form>
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
{#if $isSupporter}
<button
type="button"
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={removeKey}
>
{m.supporter_remove_key()}
</button>
{:else}
<p class="text-center text-xs text-muted-foreground">
{m.supporter_no_key_yet()}
<a
href={SIGNUP_URL}
target="_blank"
rel="noopener"
class="font-semibold text-primary underline-offset-2 hover:underline"
>
{m.supporter_support_from({ price })}
</a>
</p>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+86
View File
@@ -0,0 +1,86 @@
import * as m from '$lib/paraglide/messages';
/**
* 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_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 SUPPORTER_API_BASE = stripTrailingSlash(
env.VITE_SUPPORTER_API_BASE ?? env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
);
/** 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 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' };
const US_ZONES =
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
export type SupporterCurrency = 'EUR' | 'USD' | 'CHF';
export function detectCurrency(): SupporterCurrency {
if (typeof Intl === 'undefined') return 'EUR';
let tz = '';
try {
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
/* ignore */
}
if (/Zurich|Vaduz/.test(tz)) return 'CHF';
let region = '';
try {
if (typeof navigator !== 'undefined') {
region = new Intl.Locale(navigator.language).maximize().region || '';
}
} catch {
/* ignore */
}
if (region === 'CH') return 'CHF';
if (region === 'US' || US_ZONES.test(tz)) return 'USD';
return 'EUR';
}
/** 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.
const configured = env.VITE_SUPPORTER_PRICE ?? env.VITE_PREMIUM_PRICE;
if (configured) return configured;
const currency = detectCurrency();
const symbol = CURRENCY_SYMBOL[currency];
const amount = currency === 'CHF' ? `${symbol} 3` : `${symbol}3`;
return m.supporter_price_per_month({ amount });
}
/** Short, human list of what supporting unlocks (shown on the locked panel).
* A function, not a constant: the copy has to follow the active locale. */
export const SUPPORTER_PERKS = (): string[] => [
m.supporter_perk_historical(),
m.supporter_perk_seasonal(),
m.supporter_perk_future()
];
+161
View File
@@ -0,0 +1,161 @@
/**
* Supporter (subscription) state.
*
* The user pastes an access key once; it is stored locally and re-verified
* against the self-hosted verify API on load. The last good result is cached so
* gated content shows instantly on reload (and keeps working briefly offline)
* without waiting for the network round-trip.
*
* 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
* verify API's repo) is what makes it meaningful in practice.
*/
import { derived, get, writable } from 'svelte/store';
import { persisted } from 'svelte-persisted-store';
import * as m from '$lib/paraglide/messages';
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', '');
export interface SupporterCache {
valid: boolean;
tier?: string;
/** ISO date the subscription lapses, or null for a lifetime key. */
expires?: string | null;
/** epoch ms of the last verify call */
checkedAt: number;
}
const CACHE_KEY = 'supporter_cache_v1';
const LEGACY_CACHE_KEY = 'premium_cache_v1';
/**
* Carries the pre-rename cache over on first load, so supporters who already
* verified aren't shown a locked page while the key re-verifies.
*/
function readLegacyCache(): SupporterCache | null {
if (typeof localStorage === 'undefined') return null;
try {
if (localStorage.getItem(CACHE_KEY)) return null;
const legacy = localStorage.getItem(LEGACY_CACHE_KEY);
if (!legacy) return null;
localStorage.removeItem(LEGACY_CACHE_KEY);
return JSON.parse(legacy) as SupporterCache;
} catch {
return null;
}
}
/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
export const storedSupporterCache = persisted<SupporterCache | null>(CACHE_KEY, readLegacyCache());
export type SupporterStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
export interface SupporterState {
status: SupporterStatus;
tier?: string;
expires?: string | null;
error?: string;
}
/** Live verification state for the current session. */
export const supporterState = writable<SupporterState>({ status: 'idle' });
function notExpired(expires: string | null | undefined): boolean {
if (!expires) return true; // lifetime key
const t = Date.parse(expires);
return Number.isFinite(t) && t > Date.now();
}
/**
* 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));
}
);
export interface VerifyResult {
valid: boolean;
tier?: string;
expires?: string | null;
error?: string;
}
/**
* Verify a key against the API. On success the key is persisted and the cache
* updated. On an invalid key the stored key is left untouched (so an expired
* 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' });
return { valid: false, error: m.supporter_enter_key() };
}
supporterState.set({ status: 'checking' });
try {
const res = await fetch(`${SUPPORTER_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
headers: { accept: 'application/json' }
});
const data = (await res.json()) as VerifyResult;
if (res.ok && data.valid) {
storedLicenseKey.set(trimmed);
storedSupporterCache.set({
valid: true,
tier: data.tier,
expires: data.expires ?? null,
checkedAt: Date.now()
});
supporterState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
return { valid: true, tier: data.tier, expires: data.expires ?? null };
}
storedSupporterCache.set({ valid: false, checkedAt: Date.now() });
supporterState.set({ status: 'invalid' });
return { valid: false };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
supporterState.set({ status: 'error', error: message });
return { valid: false, error: message };
}
}
/** 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' });
return;
}
await verifyKey(key);
}
/** Forget the key and supporter state ("sign out"). */
export function clearLicense(): void {
storedLicenseKey.set('');
storedSupporterCache.set(null);
supporterState.set({ status: 'idle' });
}
+59 -13
View File
@@ -1,12 +1,52 @@
import { isSameDay as isSameDayDateFns } from 'date-fns';
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
import { de, enGB, es, fr, it } from 'date-fns/locale';
import * as m from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
import type { Locale as DateFnsLocale } from 'date-fns';
// Weekday and month names come from date-fns, so they have to follow the same
// 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
});
}
/**
@@ -14,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);
}
/**
@@ -31,21 +75,23 @@ 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 '';
if (isSameDayDateFns(zonedDate, zonedNow)) return 'Today';
const zonedDate = toZonedTime(d, timeZone);
const zonedNow = toZonedTime(new Date(), timeZone);
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today();
const tomorrow = new Date(zonedNow);
tomorrow.setDate(tomorrow.getDate() + 1);
if (isSameDayDateFns(zonedDate, tomorrow)) return 'Tomorrow';
if (isSameDayDateFns(zonedDate, tomorrow)) return m.day_tomorrow();
const yesterday = new Date(zonedNow);
yesterday.setDate(yesterday.getDate() - 1);
if (isSameDayDateFns(zonedDate, yesterday)) return 'Yesterday';
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
return formatInTimeZone(date, timeZone, 'EEE d MMM');
return formatZoned(d, timeZone, 'EEE d MMM');
}
/**
+98
View File
@@ -0,0 +1,98 @@
import { tick } from 'svelte';
import {
canStartViewTransition,
prefersReducedMotion,
skipActiveViewTransition,
startViewTransition,
supportsViewTransitions
} from './view-transition';
/**
* Runs a day change inside a view transition, so the outgoing day is still on
* screen while the incoming one fades in - a real cross-fade rather than the
* old content vanishing and the new one fading up from nothing.
*
* Only the regions that actually change carry a `view-transition-name` (see
* `.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 (!canStartViewTransition()) {
update();
return;
}
const token = ++dayTransitionToken;
const root = document.documentElement;
// 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. `day-switch` scopes which
// regions take part (see routes/layout.css) and is cleared when it ends.
await startViewTransition(
async () => {
update();
await tick();
},
{ rootClass: 'day-switch' }
);
} finally {
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');
}
}
/**
* Fallback for browsers without view transitions: fade the block back in when
* the value passed to it changes. Animates the existing node rather than
* remounting it, so the canvas charts keep their zoom state.
*/
export function daySwap(node: HTMLElement, key: unknown) {
let current = key;
const play = () => {
// view transitions handle it properly where they exist
if (supportsViewTransitions() || prefersReducedMotion()) return;
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
duration: 460,
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
});
};
return {
update(next: unknown) {
if (next === current) return;
current = next;
play();
}
};
}
+39 -4
View File
@@ -1,5 +1,7 @@
import { error, redirect } from '@sveltejs/kit';
import { deLocalizeHref, localizeHref } from '$lib/paraglide/runtime';
import type { GeoLocation } from '$lib/stores/settings';
export const geoLocationNameToRoute = (name: string) => {
@@ -11,7 +13,14 @@ export const geoLocationNameToRoute = (name: string) => {
// selections navigate here directly, no geocoding id involved
const COORD_ROUTE = /^(-?\d+(?:\.\d+)?)N(-?\d+(?:\.\d+)?)E$/i;
export function buildLocationRoute(location: GeoLocation): string {
/** Only the fields the route is built from, so callers holding a partial record
* (the nearby-cities list, for one) don't have to fake a whole GeoLocation. */
export type RoutableLocation = Pick<
GeoLocation,
'id' | 'name' | 'latitude' | 'longitude' | 'feature_code' | 'population'
>;
export function buildLocationRoute(location: RoutableLocation): string {
// coordinate-only locations (GPS) have no real geocoding id
if (location.feature_code === 'COORD' || !location.id) {
return `${location.latitude.toFixed(4)}N${location.longitude.toFixed(4)}E`;
@@ -67,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,
@@ -77,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;
@@ -115,12 +139,23 @@ 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.
// never holds and the redirect loops forever. The comparison also has to
// ignore the locale prefix the URL carries, while the redirect keeps it -
// otherwise every localized URL would bounce back to English.
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
if (event.url.pathname !== canonicalPath) {
throw redirect(303, canonicalPath);
if (deLocalizeHref(event.url.pathname) !== canonicalPath) {
throw redirect(303, localizeHref(canonicalPath));
}
return location;
+3 -4
View File
@@ -9,7 +9,7 @@
* automatically once the maps app ships seamless support; until then they fall
* back to their widest-coverage member (a regional member could be entirely
* off-screen). Models without an entry are tried under their own id; models
* the map does not serve at all (best_match, bom, google, UKMO ensembles)
* the map does not serve at all (best_match, google, UKMO ensembles)
* resolve to null.
*/
const modelDomainCandidates: Record<string, string[]> = {
@@ -46,9 +46,8 @@ const modelDomainCandidates: Record<string, string[]> = {
// MeteoSwiss (CH2 covers a wider area than CH1)
meteoswiss_icon_seamless: ['meteoswiss_icon_ch2'],
// KMA Korea (kma_ldps is not served by the maps project)
kma_seamless: ['kma_gdps'],
kma_ldps: ['kma_gdps'],
// CHMI Czech Republic (Central Europe is the widest native domain)
chmi_aladin_seamless: ['chmi_aladin_seamless', 'chmi_aladin_central_europe_2km'],
// JMA Japan
jma_seamless: ['jma_seamless', 'jma_gsm'],
+56
View File
@@ -0,0 +1,56 @@
import { browser } from '$app/environment';
/**
* Shared "now" clock.
*
* One timer, aligned to the wall-clock minute, drives every current-time marker
* in the app (the hourly table's NOW line, the meteogram's time marker) so they
* stay accurate without a page reload.
*
* The timer only runs while at least one component is subscribed, and re-aligns
* after each tick so it can't drift or fire twice within a minute (e.g. after
* the tab has been suspended).
*/
let current = $state(new Date());
let subscribers = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const MINUTE_MS = 60_000;
function scheduleTick() {
timer = setTimeout(
() => {
current = new Date();
scheduleTick();
},
MINUTE_MS - (Date.now() % MINUTE_MS)
);
}
/**
* Reactive current time, refreshed on every minute boundary while the calling
* component is mounted. On the server it just reports render time.
*/
export function useNow(): { readonly current: Date } {
if (browser) {
$effect(() => {
if (subscribers++ === 0) {
current = new Date();
scheduleTick();
}
return () => {
if (--subscribers === 0) {
clearTimeout(timer);
timer = undefined;
}
};
});
}
return {
get current() {
return browser ? current : new Date();
}
};
}
+44
View File
@@ -0,0 +1,44 @@
import type { TransitionConfig } from 'svelte/transition';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* Fades a loading placeholder out *over* the content that replaces it.
*
* A plain `out:fade` is not usable on a skeleton: Svelte keeps the node in the
* layout for the length of the transition, so the real content stacks below the
* placeholder and the whole page jumps up the moment the placeholder finally
* unmounts. That is why these placeholders only ever faded in - the swap was
* abrupt, but at least nothing moved.
*
* This pins the placeholder to the box it already occupies and takes it out of
* flow for the fade instead. The content settles into its final position
* immediately and the skeleton dissolves on top of it.
*
* Requirements at the call site: the placeholder's parent must be positioned
* (`class="relative"`) and must be the same element that renders the real
* content, or the overlay lands somewhere else on the page.
*/
export function skeletonOut(node: HTMLElement, { duration = 260 } = {}): TransitionConfig {
if (prefersReducedMotion()) return { duration: 0 };
// measured while the node is still in flow, which is when Svelte builds the
// transition - the values below are what freeze it in place
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node;
return {
duration,
css: (t) => `
opacity: ${t};
position: absolute;
top: ${offsetTop}px;
left: ${offsetLeft}px;
width: ${offsetWidth}px;
height: ${offsetHeight}px;
margin: 0;
pointer-events: none;
z-index: 5;
`
};
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Mirrors UI state into the query string so a view can be linked and reloaded
* exactly as it was: which day is open, which model is plotted, which variables
* are compared.
*
* Writes use `replaceState` rather than `goto`, so mirroring state never adds a
* history entry or re-runs a load - the back button still means "the page
* before", not "the previous day I clicked".
*
* 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(updates: Record<string, string | null>): void {
if (!browser) return;
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 === current.href) return;
try {
replaceState(next, {});
} catch {
// A page whose state settles during mount can get here before the router
// has taken over. The URL is cosmetic, so retry on the next frame rather
// than letting it break the page.
requestAnimationFrame(() => {
try {
replaceState(next, {});
} catch {
/* give up: the view still works, it just isn't linkable yet */
}
});
}
}
/**
* 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);
if (!raw) return null;
const list = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return list.length > 0 ? list : null;
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Central entry point for every view transition in the app.
*
* Two things it exists to get right.
*
* **One transition at a time, but only where it matters.** Starting a
* transition while another is running makes the browser skip the first, and
* what that looks like depends entirely on which phase the first is in:
*
* - *capturing* - its update callback has not resolved yet, so the screen is
* frozen on the outgoing snapshot. Skipping drops that snapshot on the spot
* and pops the half-updated live DOM into view: the whole-screen flash.
* This phase is common here, because navigation deliberately holds the
* callback open while the new page fetches, and `/` plus every bare
* `/weather/<view>/` page is a redirect stub that navigates again from
* `onMount` - one click, two or three navigations.
* - *animating* - the DOM is already in its final state and the pseudo
* elements are playing out. Skipping just finishes them early, landing on
* exactly the state they were heading for.
*
* So a new transition rides along inside the running one only while it is
* capturing; once it is animating, superseding it is the better answer (waiting
* would strand the new update under a stale snapshot until the animation ends).
*
* **A scoping class.** `rootClass` is set on `<html>` for the life of the
* transition, so the stylesheet can tell a page swap from a day switch and pin
* the parts that are identical on both sides (see routes/layout.css).
*/
type UpdateCallback = () => void | Promise<void>;
interface Options {
/** Class set on `<html>` while the transition runs, for scoping CSS. */
rootClass?: string;
/**
* Set false to run the update without a transition. For content the browser
* does not paint into a snapshot - a cross-origin iframe - where animating
* means animating a hole rather than a cross-fade.
*/
enabled?: boolean;
}
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
let capturing: Promise<void> | null = null;
/**
* How many running transitions hold each scoping class. A superseded
* transition's cleanup fires while its successor is mid-capture (skipping
* rejects `finished` on a microtask, which runs before the next render);
* without the count it would strip the class out from under the successor,
* and a day switch captured without `day-switch` falls back to the full-page
* fade it exists to prevent.
*/
const rootClassHolds = new Map<string, number>();
/** The most recently started transition, while it is capturing or animating. */
let active: ViewTransition | null = null;
/**
* Finishes the running transition's animation on the spot (the DOM is already
* in its final state, so this is always safe). Used to hand the screen back
* the moment the user starts scrolling under a day switch, rather than
* animating against a moving target.
*/
export function skipActiveViewTransition(): void {
active?.skipTransition();
}
export const supportsViewTransitions = (): boolean =>
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
export const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* True while a transition is frozen on its outgoing snapshot - the phase in
* which starting a rival transition would flash the page.
*/
export const isViewTransitionCapturing = (): boolean => capturing !== null;
/** Whether `startViewTransition` would actually open one right now. */
export const canStartViewTransition = (): boolean =>
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionCapturing();
/**
* Runs `update` inside a view transition, or straight away when one cannot (or
* must not) be started. Resolves once the transition has finished animating -
* or as soon as the update is done, when it ran on its own.
*/
export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
const { rootClass, enabled = true } = options;
if (!enabled || !canStartViewTransition()) {
return Promise.resolve(update()).then(
() => {},
(error: unknown) => {
console.error('view transition update failed', error);
}
);
}
const root = document.documentElement;
if (rootClass) {
rootClassHolds.set(rootClass, (rootClassHolds.get(rootClass) ?? 0) + 1);
root.classList.add(rootClass);
}
const transition = document.startViewTransition(update);
active = transition;
// A throw inside the callback rejects `updateCallbackDone` as well as
// `finished`. Nothing awaits the former, and an unhandled rejection there is
// what turns one bad render into an "Uncaught" error on the page.
const captured = transition.updateCallbackDone.then(
() => {},
(error: unknown) => {
console.error('view transition update failed', error);
}
);
capturing = captured;
void captured.then(() => {
if (capturing === captured) capturing = null;
});
// A superseded transition rejects `finished`; the DOM is already up to date,
// so that is not an error worth surfacing.
return transition.finished
.then(
() => {},
() => {}
)
.finally(() => {
if (active === transition) active = null;
if (!rootClass) return;
const holds = (rootClassHolds.get(rootClass) ?? 1) - 1;
if (holds > 0) {
rootClassHolds.set(rootClass, holds);
} else {
rootClassHolds.delete(rootClass);
root.classList.remove(rootClass);
}
});
}
+313 -9
View File
@@ -1,14 +1,31 @@
<script lang="ts">
import { tick } from 'svelte';
import { get } from 'svelte/store';
import { fade, fly } from 'svelte/transition';
import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores';
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';
@@ -16,20 +33,242 @@
// keep the .dark class in sync with the persisted theme; in 'system' mode
// follow the OS preference live
let themeSettled = false;
let themeTimer = 0;
$effect(() => {
const theme = $storedTheme;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
const root = document.documentElement;
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
document.documentElement.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.
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!themeSettled || reduced) {
themeSettled = true;
paint();
return;
}
if (canStartViewTransition() && !onMapsPage()) {
// one cross-fade of the whole document; component transitions untouched
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;
}
// no view transitions: fall back to fading the colours for one window
root.classList.add('theme-transition');
clearTimeout(themeTimer);
themeTimer = window.setTimeout(() => root.classList.remove('theme-transition'), 400);
paint();
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
// ── 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
// 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.
//
// 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;
// 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);
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);
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]',
'/weather/seasonal/[location]',
'/weather/historical/[location]'
]);
/**
* 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, 20));
}
if (!get(pageContentReady)) showOverlay(!underTransition);
// one more frame so the page paints its data before the snapshot is taken
await tick();
}
/**
* 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 ?? {});
}
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();
}
return new Promise<void>((swap) => {
void startViewTransition(
async () => {
// hand control back so SvelteKit swaps the DOM underneath the
// frozen snapshot of the old page
swap();
// A superseded navigation (a redirect, or a fast second click)
// rejects this promise; that is not an error worth surfacing,
// and leaving it unhandled shows up as "navigation aborted".
await navigation.complete.catch(() => {});
if (pending) await waitForContent(underTransition);
},
// pins the chrome that is the same on both sides (routes/layout.css)
{ rootClass: 'page-switch', enabled: underTransition }
);
});
});
let mainEl = $state<HTMLElement | null>(null);
afterNavigate((navigation) => {
// The departure snapshot (if any) is taken by now, so the maps cover has
// done its job; lowering it here also means a later visit to the maps page
// starts from its own boot cover rather than a stuck one.
mapTransitionCover.set(false);
// The page scrolls inside <main>, not the window, so SvelteKit's own scroll
// handling never touches it and a new page would open half way down.
// Back/forward and in-page anchors keep their position.
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
mainEl?.scrollTo({ top: 0 });
}
});
// the maps page embeds a full-bleed map: no padding, no scrolling
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps'));
let sidebarCollapsed = $state(false);
let mobileMenuOpen = $state(false);
@@ -48,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>
@@ -82,17 +373,30 @@
<div class="flex min-w-0 flex-1 flex-col h-full">
<Header onMenuToggle={toggleMobileMenu} />
<!-- The padding stays on <main> itself: the day strip sticks with a
negative offset that exactly cancels it, so moving it to an inner
wrapper would dock the strip too high and clip its top row.
flex-col + flex-1 below keeps the footer on the bottom edge even when
the page is too short to fill the viewport. -->
<main
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 md:px-8 md:py-6'}
bind:this={mainEl}
class={fullBleed
? 'flex-1 overflow-hidden'
: 'flex flex-1 flex-col overflow-y-auto p-3 lg:px-8 lg:py-6'}
>
{#if fullBleed}
{@render children()}
{:else}
<!-- cap the content width on very large screens; generous bottom room
so the last chart/table never sits flush against the viewport edge -->
<div class="mx-auto w-full max-w-[1536px] pb-80">
<!-- 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="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),
cancelling main's padding so it sits flush with the edges -->
<div class="-mx-3 -mb-3 lg:-mx-8 lg:-mb-6">
<Footer />
</div>
{/if}
</main>
</div>
+16 -3
View File
@@ -1,3 +1,16 @@
<svelte:head>
<title>Drizzli</title>
</svelte:head>
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { localizeHref } from '$lib/paraglide/runtime';
// Deliberately a client-side redirect, not one from `load`: this page is
// prerendered, so a redirect resolved at build time would bake in the base
// locale and every visitor would land on English. Deciding it here lets the
// locale strategy read the URL, the cookie and finally the browser's own
// languages before choosing.
onMount(() => {
goto(localizeHref('/weather/week/'), { replaceState: true });
});
</script>
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async () => {
throw redirect(303, '/weather/week/');
}) satisfies PageLoad;
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import LocalizedContent from '$lib/components/localized-content.svelte';
import de from './content/de.svelte';
import en from './content/en.svelte';
import es from './content/es.svelte';
import fr from './content/fr.svelte';
import it from './content/it.svelte';
</script>
<LocalizedContent variants={{ en, de, es, fr, it }} />
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Über Drizz.li" subtitle="Eine kleine, quelloffene Wetterseite.">
<p>
Drizz.li ist eine unabhängige Wetterseite. Sie zeigt Vorhersagen mehrerer Modelle,
Modellvergleiche, 14-Tage-Aussichten, Wetterkarten und historisches Wetter - ohne Konten,
Werbung oder Tracker.
</p>
<h2>So funktioniert es</h2>
<p>
Alle Wetterdaten stammen aus den hervorragenden Open-Data-Schnittstellen von
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, die nationale
Wetterdienste bündeln (DWD, NOAA, Météo-France, MeteoSchweiz und weitere). Drizz.li ist eine
statische Seite: Die Vorhersagen werden direkt von Ihrem Browser abgerufen und auf Ihrem Gerät
dargestellt.
</p>
<p>
Die Seite ist quelloffen. Jede und jeder kann den Code lesen, Fehler melden oder Verbesserungen
beitragen.
</p>
<h2>Das Projekt unterstützen</h2>
<p>
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, 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
gespeicherten Kartendaten. Wie das genau abläuft, steht in den
<a href={href('/legal/terms')}>Unterstützerbedingungen</a> und in der
<a href={href('/legal/privacy')}>Datenschutzerklärung</a>.
</p>
<h2>Kontakt</h2>
<p>
Fragen, Rückmeldungen oder Fehlerberichte:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="About Drizz.li" subtitle="A small, open-source weather site.">
<p>
Drizz.li is an independent weather site. It shows multi-model forecasts, model comparisons,
14-day outlooks, weather maps and historical weather - without accounts, ads or trackers.
</p>
<h2>How it works</h2>
<p>
All weather data comes from the excellent open-data APIs of
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, which aggregates
national weather services (DWD, NOAA, Météo-France, MeteoSwiss and more). Drizz.li is a static
site: forecasts are fetched directly by your browser and rendered on your device.
</p>
<p>
The site is open source. Anyone can read the code, report issues or contribute improvements.
</p>
<h2>Supporting the project</h2>
<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, 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
details. The details of how that works are in the
<a href={href('/legal/terms')}>supporter terms</a> and the
<a href={href('/legal/privacy')}>privacy policy</a>.
</p>
<h2>Contact</h2>
<p>
Questions, feedback or bug reports:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Sobre Drizz.li" subtitle="Un pequeño sitio meteorológico de código abierto.">
<p>
Drizz.li es un sitio meteorológico independiente. Muestra pronósticos de varios modelos,
comparaciones entre modelos, previsiones a 14 días, mapas meteorológicos y datos históricos, sin
cuentas, sin anuncios y sin rastreadores.
</p>
<h2>Cómo funciona</h2>
<p>
Todos los datos proceden de las excelentes API de datos abiertos de
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, que agrega los
servicios meteorológicos nacionales (DWD, NOAA, Météo-France, MeteoSwiss y más). Drizz.li es un
sitio estático: tu navegador descarga los pronósticos directamente y los representa en tu
dispositivo.
</p>
<p>
El sitio es de código abierto. Cualquiera puede leer el código, informar de errores o contribuir
con mejoras.
</p>
<h2>Apoyar el proyecto</h2>
<p>
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, 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
sin guardar datos de tarjetas. Los detalles están en las
<a href={href('/legal/terms')}>condiciones para colaboradores</a> y en la
<a href={href('/legal/privacy')}>política de privacidad</a>.
</p>
<h2>Contacto</h2>
<p>
Dudas, comentarios o informes de errores:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="À propos de Drizz.li" subtitle="Un petit site météo open source.">
<p>
Drizz.li est un site météo indépendant. Il propose des prévisions multi-modèles, des
comparaisons de modèles, des tendances à 14 jours, des cartes météo et des données historiques,
sans compte, sans publicité et sans traceurs.
</p>
<h2>Comment ça marche</h2>
<p>
Toutes les données proviennent des excellentes API ouvertes d'<a
href="https://open-meteo.com"
target="_blank"
rel="noopener">Open-Meteo</a
>, qui agrègent les services météorologiques nationaux (DWD, NOAA, Météo-France, MeteoSwiss et
d'autres). Drizz.li est un site statique : les prévisions sont récupérées directement par votre
navigateur et affichées sur votre appareil.
</p>
<p>
Le site est open source. Chacun peut lire le code, signaler un problème ou proposer des
améliorations.
</p>
<h2>Soutenir le projet</h2>
<p>
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, 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,
aucune donnée de carte conservée. Le détail figure dans les
<a href={href('/legal/terms')}>conditions contributeur</a> et dans la
<a href={href('/legal/privacy')}>politique de confidentialité</a>.
</p>
<h2>Contact</h2>
<p>
Questions, retours ou signalements de bugs :
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Informazioni su Drizz.li" subtitle="Un piccolo sito meteo open source.">
<p>
Drizz.li è un sito meteo indipendente. Offre previsioni multi-modello, confronti tra modelli,
tendenze a 14 giorni, mappe meteo e dati storici, senza account, senza pubblicità e senza
tracciatori.
</p>
<h2>Come funziona</h2>
<p>
Tutti i dati provengono dalle ottime API aperte di
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, che aggregano i
servizi meteorologici nazionali (DWD, NOAA, Météo-France, MeteoSwiss e altri). Drizz.li è un
sito statico: le previsioni vengono scaricate direttamente dal tuo browser e disegnate sul tuo
dispositivo.
</p>
<p>
Il sito è open source. Chiunque può leggere il codice, segnalare problemi o contribuire con
miglioramenti.
</p>
<h2>Sostenere il progetto</h2>
<p>
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, 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
dato della carta conservato. I dettagli sono nelle
<a href={href('/legal/terms')}>condizioni per i sostenitori</a> e nell'<a
href={href('/legal/privacy')}>informativa sulla privacy</a
>.
</p>
<h2>Contatti</h2>
<p>
Domande, riscontri o segnalazioni di bug:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
</ProsePage>
+155
View File
@@ -132,4 +132,159 @@
body {
@apply bg-background text-foreground;
}
/* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part
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;
}
:root.day-switch .day-region-summary {
view-transition-name: day-summary;
}
:root.day-switch .day-region-charts {
view-transition-name: day-charts;
}
/* 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);
}
/* 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),
::view-transition-new(day-summary),
::view-transition-old(day-charts),
::view-transition-new(day-charts) {
animation-duration: 420ms;
animation-timing-function: ease;
mix-blend-mode: plus-lighter;
}
/* 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;
}
::view-transition-new(topbar),
::view-transition-new(sidebar) {
animation: none;
opacity: 1;
}
/* Fallback for browsers without view transitions: fade the colours only. */
:root.theme-transition,
:root.theme-transition *,
:root.theme-transition *::before,
:root.theme-transition *::after {
transition:
background-color 400ms ease,
border-color 400ms ease,
color 400ms ease,
fill 400ms ease,
stroke 400ms ease;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
:root.theme-transition,
:root.theme-transition *,
:root.theme-transition *::before,
:root.theme-transition *::after {
transition: none;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import LocalizedContent from '$lib/components/localized-content.svelte';
import de from './content/de.svelte';
import en from './content/en.svelte';
import es from './content/es.svelte';
import fr from './content/fr.svelte';
import it from './content/it.svelte';
</script>
<LocalizedContent variants={{ en, de, es, fr, it }} />
@@ -0,0 +1,32 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Impressum" subtitle="Anbieterkennzeichnung.">
<h2>Diensteanbieter</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2<br />
CH-6442 Gersau<br />
Switzerland
</address>
<p>
E-Mail: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
<p>Drizz.li wird als persönliches, unabhängiges Projekt betrieben.</p>
<h2>Haftungsausschluss</h2>
<p>
Die hier gezeigten Wetterdaten dienen ausschliesslich der allgemeinen Information. Vorhersagen
sind naturgemäss unsicher; verlassen Sie sich nicht auf sie, wenn Leben, Gesundheit oder
Sachwerte auf dem Spiel stehen - massgeblich sind die amtlichen Warnungen Ihres nationalen
Wetterdienstes.
</p>
<p>
Wetter- und Geodaten stammen von
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, das nicht mit
dieser Seite verbunden ist. Externe Links werden nach bestem Wissen gesetzt; für deren Inhalte
sind die jeweiligen Betreiber verantwortlich.
</p>
<p class="text-muted-foreground">Massgeblich ist die englische Fassung dieses Impressums.</p>
</ProsePage>
@@ -0,0 +1,30 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Imprint" subtitle="Legal notice / provider identification.">
<h2>Service provider</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2<br />
CH-6442 Gersau<br />
Switzerland
</address>
<p>
Email: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
<p>Drizz.li is operated as a personal, independent project.</p>
<h2>Disclaimer</h2>
<p>
Weather data shown on this site is provided for general information only. Forecasts are
inherently uncertain; do not rely on them where life, health or property is at stake - consult
official warnings from your national weather service instead.
</p>
<p>
Weather and geodata are retrieved from
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, which is not
affiliated with this site. External links are provided in good faith; their content is the
responsibility of the respective operators.
</p>
</ProsePage>
@@ -0,0 +1,33 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Aviso legal" subtitle="Identificación del prestador del servicio.">
<h2>Prestador del servicio</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2<br />
CH-6442 Gersau<br />
Switzerland
</address>
<p>
Correo electrónico: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
<p>Drizz.li se gestiona como un proyecto personal e independiente.</p>
<h2>Descargo de responsabilidad</h2>
<p>
Los datos meteorológicos de este sitio son solo informativos. Los pronósticos son
intrínsecamente inciertos; no confíes en ellos cuando estén en juego la vida, la salud o los
bienes: consulta los avisos oficiales de tu servicio meteorológico nacional.
</p>
<p>
Los datos meteorológicos y geográficos proceden de
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, que no está
afiliado a este sitio. Los enlaces externos se ofrecen de buena fe; su contenido es
responsabilidad de sus respectivos operadores.
</p>
<p class="text-muted-foreground">
En caso de discrepancia prevalece la versión inglesa de este aviso legal.
</p>
</ProsePage>
@@ -0,0 +1,33 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Mentions légales" subtitle="Identification de l'éditeur.">
<h2>Éditeur du service</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2<br />
CH-6442 Gersau<br />
Switzerland
</address>
<p>
E-mail: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
<p>Drizz.li est exploité comme un projet personnel et indépendant.</p>
<h2>Avertissement</h2>
<p>
Les données météo présentées ici sont fournies à titre d'information générale. Les prévisions
sont par nature incertaines ; ne vous y fiez pas lorsque des vies, la santé ou des biens sont en
jeu - consultez les alertes officielles de votre service météorologique national.
</p>
<p>
Les données météo et géographiques proviennent d'
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, qui n'est pas
affilié à ce site. Les liens externes sont fournis de bonne foi ; leur contenu relève de la
responsabilité de leurs exploitants.
</p>
<p class="text-muted-foreground">
En cas de divergence, la version anglaise de ces mentions fait foi.
</p>
</ProsePage>
@@ -0,0 +1,33 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Note legali" subtitle="Identificazione del fornitore del servizio.">
<h2>Fornitore del servizio</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2<br />
CH-6442 Gersau<br />
Switzerland
</address>
<p>
E-mail: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</p>
<p>Drizz.li è gestito come progetto personale e indipendente.</p>
<h2>Avvertenza</h2>
<p>
I dati meteo mostrati su questo sito hanno finalità puramente informative. Le previsioni sono
per natura incerte: non farvi affidamento quando sono in gioco la vita, la salute o i beni -
consulta gli avvisi ufficiali del tuo servizio meteorologico nazionale.
</p>
<p>
I dati meteo e geografici provengono da
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, che non è
affiliato a questo sito. I link esterni sono forniti in buona fede; i contenuti sono
responsabilità dei rispettivi gestori.
</p>
<p class="text-muted-foreground">
In caso di discrepanza prevale la versione inglese di queste note legali.
</p>
</ProsePage>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import LocalizedContent from '$lib/components/localized-content.svelte';
import de from './content/de.svelte';
import en from './content/en.svelte';
import es from './content/es.svelte';
import fr from './content/fr.svelte';
import it from './content/it.svelte';
</script>
<LocalizedContent variants={{ en, de, es, fr, it }} />
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Datenschutzerklärung" subtitle="Zuletzt aktualisiert: 1. August 2026">
<p>
Drizz.li ist so gebaut, dass möglichst wenige Ihrer Daten nötig sind: keine Benutzerkonten,
keine Cookies, keine Werbung, keine Analyse- oder Tracking-Skripte. Diese Seite erklärt, welche
wenige Verarbeitung dennoch stattfindet - beim Besuch der Seite und wenn Sie das Projekt mit
einem Beitrag unterstützen.
</p>
<h2>1. Verantwortlicher</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
E-Mail:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</address>
<p>
Diese Erklärung gilt für die Websites drizz.li (die Wetter-App) und support.drizz.li (die
Unterstützer-Anmeldung).
</p>
<h2>2. Besuch der Website</h2>
<h3>Abruf der Wetterdaten</h3>
<p>
Drizz.li ist eine statische Seite: Wenn Sie eine Vorhersage öffnen, ruft Ihr Browser die
Wetterdaten direkt bei den Open-Data-Schnittstellen von
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Wie bei jeder
Webanfrage werden dabei Ihre IP-Adresse und der gewünschte Ort bzw. Suchbegriff an Open-Meteo
übertragen. Wir erhalten und speichern davon nichts. Siehe
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
>Nutzungs- und Datenschutzhinweise von Open-Meteo</a
>. Rechtsgrundlage: unser berechtigtes Interesse an der Auslieferung der angeforderten Inhalte
(Art. 6 Abs. 1 lit. f DSGVO).
</p>
<h3>Hosting</h3>
<p>
Die statische Seite wird von [Hosting-Anbieter, Standort] ausgeliefert. Die
Hosting-Infrastruktur kann kurzlebige technische Server-Logs (IP-Adresse, angefragte URL,
Zeitstempel) zu Sicherheits- und Betriebszwecken speichern. Rechtsgrundlage: berechtigtes
Interesse an einem sicheren, zuverlässigen Dienst (Art. 6 Abs. 1 lit. f DSGVO).
</p>
<h3>Einstellungen auf Ihrem Gerät (Local Storage)</h3>
<p>
Ihre Einstellungen - Design, Masseinheiten, zuletzt gesuchter Ort und (für Unterstützer) Ihr
Zugangsschlüssel samt letztem Prüfergebnis - werden ausschliesslich im Local Storage Ihres
Browsers gespeichert. Sie verlassen Ihr Gerät nicht, ausser wie unten für die Schlüsselprüfung
beschrieben, und Sie können sie jederzeit über Ihren Browser löschen.
</p>
<h2>3. Das Projekt unterstützen (Beiträge)</h2>
<p>
Unterstützerbeiträge schalten die Extras frei, rechtlich sind sie daher eine entgeltliche
Vereinbarung und keine reine Schenkung - das verarbeiten wir zur Abwicklung:
</p>
<h3>Anmeldeformular</h3>
<ul>
<li>
<strong>E-Mail-Adresse</strong> (erforderlich) - um Ihnen die Überweisungsdaten und nach Eingang
Ihres Beitrags den Zugangsschlüssel zu senden.
</li>
<li><strong>Name</strong> (optional) - zur leichteren Zuordnung Ihrer Überweisung.</li>
<li>
<strong>Währung und Betrag</strong> - anhand der Spracheinstellung/Zeitzone Ihres Browsers auf Ihrem
Gerät ermittelt (keine Standortabfrage).
</li>
<li>
<strong>IP-Adresse und technische Anti-Bot-Signale</strong> - zusammen mit der Anfrage gespeichert,
um Missbrauch und Spam zu verhindern.
</li>
<li>
<strong>Zahlungsreferenz</strong> (z. B. DRZ-XXXXXX) - je Anfrage erzeugt, um Ihre Überweisung Ihrer
Anmeldung zuzuordnen.
</li>
</ul>
<p>
Die Überweisungsdaten (Bankverbindung und Referenz) senden wir Ihnen per E-Mail statt sie auf
der Seite anzuzeigen. Rechtsgrundlagen: Erfüllung der Vereinbarung (Art. 6 Abs. 1 lit. b DSGVO)
und für die Missbrauchsabwehr berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO).
</p>
<h3>Banküberweisung</h3>
<p>
Beiträge werden per gewöhnlicher Banküberweisung gezahlt. Ihre und unsere Bank verarbeiten die
Überweisungsdaten in eigener Verantwortung; auf unserem Kontoauszug sehen wir die üblichen
Angaben (Name, Kontonummer, Betrag, Referenz). Wir nutzen sie nur zur Zuordnung Ihres Beitrags
und sind nach handels- und steuerrechtlichen Vorschriften zur Aufbewahrung verpflichtet (Art. 6
Abs. 1 lit. c DSGVO).
</p>
<h3>Zugangsschlüssel und Prüfung</h3>
<p>
Nach Zuordnung Ihrer Überweisung (manuell, meist innerhalb von 24 Stunden) erhalten Sie den
Zugangsschlüssel per E-Mail. Wenn Sie ihn in Drizz.li einfügen, sendet Ihr Browser den Schlüssel
an unseren Prüfendpunkt (support.drizz.li), um seine Gültigkeit zu prüfen; die Antwort enthält
nur Gültigkeit, Stufe und Ablauf. Die Abonnentenliste (Schlüssel, E-Mail, Ablauf) liegt auf
unserem Server, solange Ihr Zugang aktiv ist.
</p>
<h3>E-Mail</h3>
<p>
Transaktions-E-Mails (Überweisungsdaten, Zugangsschlüssel, Verlängerungen) versenden wir über
unseren E-Mail-Anbieter Strato (Strato AG, Deutschland) als Auftragsverarbeiter. Newsletter oder
Werbe-E-Mails versenden wir nicht.
</p>
<h2>4. Speicherdauer</h2>
<ul>
<li>Nicht bezahlte Anmeldungen werden spätestens nach 6 Monaten gelöscht.</li>
<li>
Unterstützerdaten (Schlüssel, E-Mail, Ablauf) werden für die Dauer Ihres Zugangs aufbewahrt
und innerhalb von 12 Monaten nach dessen Ablauf gelöscht, sofern Sie keine frühere Löschung
wünschen.
</li>
<li>
Buchhaltungsunterlagen (Kontoauszüge mit Ihrer Überweisung) werden für die gesetzliche
Aufbewahrungsfrist gespeichert (je nach Rechtsordnung bis zu 10 Jahre).
</li>
</ul>
<h2>5. Empfänger</h2>
<p>
Wir verkaufen personenbezogene Daten nicht und geben sie nicht für eigene Zwecke Dritter weiter.
Empfänger sind ausschliesslich: Open-Meteo (Wetterabrufe direkt durch Ihren Browser), unser
Hosting-Anbieter, unser E-Mail-Anbieter (Strato) und die an der Überweisung beteiligten Banken.
</p>
<h2>6. Ihre Rechte</h2>
<p>
Nach der DSGVO haben Sie das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der
Verarbeitung, Datenübertragbarkeit sowie das Recht, einer auf berechtigtem Interesse beruhenden
Verarbeitung zu widersprechen. Beruht eine Verarbeitung auf Einwilligung, können Sie diese
jederzeit widerrufen. Zur Ausübung schreiben Sie an
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. Ausserdem haben
Sie das Recht, sich bei einer Aufsichtsbehörde zu beschweren, insbesondere im EU-Mitgliedstaat
Ihres Wohnsitzes, oder bei [zuständige Aufsichtsbehörde des Verantwortlichen].
</p>
<p>
Für Besucherinnen und Besucher aus der Schweiz gelten die entsprechenden Rechte nach dem
Bundesgesetz über den Datenschutz (DSG) sinngemäss.
</p>
<h2>7. Änderungen</h2>
<p>
Wir aktualisieren diese Erklärung, wenn sich der Dienst ändert; das Datum oben nennt die letzte
Fassung.
</p>
<p class="text-muted-foreground">
Massgeblich ist die englische Fassung dieser Datenschutzerklärung.
</p>
</ProsePage>
+145
View File
@@ -0,0 +1,145 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Privacy policy" subtitle="Last updated: 1 August 2026">
<p>
Drizz.li is built to need as little of your data as possible: there are no user accounts, no
cookies, no advertising and no analytics or tracking scripts. This page explains what little
processing does happen - when you browse the site, and when you support the project with a
contribution.
</p>
<h2>1. Controller</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
Email:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</address>
<p>
This policy covers the websites drizz.li (the weather app) and support.drizz.li (the supporter
signup page).
</p>
<h2>2. Browsing the site</h2>
<h3>Weather data requests</h3>
<p>
Drizz.li is a static site: when you open a forecast, your browser fetches the weather data
directly from the open-data APIs of
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>
(api.open-meteo.com, geocoding-api.open-meteo.com, archive-api.open-meteo.com, ensemble-api.open-meteo.com,
and map tiles from maps.open-meteo.com / map-tiles.open-meteo.com / map-assets.open-meteo.com). Like
any web request, this transmits your IP address and the requested location or search term to Open-Meteo.
We do not receive or store any of this. See
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
>Open-Meteo's terms and privacy information</a
>. Legal basis: our legitimate interest in delivering the content you request (Art. 6(1)(f)
GDPR).
</p>
<h3>Hosting</h3>
<p>
The static site is served by [hosting provider, location]. The hosting infrastructure may keep
short-lived technical server logs (IP address, requested URL, timestamp) for security and
operations. Legal basis: legitimate interest in a secure, reliable service (Art. 6(1)(f) GDPR).
</p>
<h3>Settings on your device (local storage)</h3>
<p>
Your preferences - theme, measurement units, last searched location, and (for supporters) your
access key and its last verification result - are stored only in your browser's local storage.
They never leave your device except as described below for key verification, and you can clear
them at any time via your browser.
</p>
<h2>3. Supporting the project (contributions)</h2>
<p>
Supporter contributions unlock the supporter extras, so legally they are a paid agreement, not a
pure gift - and this is what we process to handle them:
</p>
<h3>Signup form</h3>
<ul>
<li>
<strong>Email address</strong> (required) - to send you the transfer details and, after your contribution
arrives, your access key.
</li>
<li><strong>Name</strong> (optional) - to help match your bank transfer.</li>
<li>
<strong>Currency and amount</strong> - shown based on your browser's locale/timezone, detected on
your device (no geolocation request is made).
</li>
<li>
<strong>IP address and technical anti-bot signals</strong> - kept with the signup request to prevent
abuse and spam of the form.
</li>
<li>
<strong>Payment reference</strong> (e.g. DRZ-XXXXXX) - generated per request to match your transfer
to your signup.
</li>
</ul>
<p>
The transfer details (bank account and reference) are sent to you by email rather than shown on
the page. Legal bases: performance of the agreement (Art. 6(1)(b) GDPR) and, for the anti-abuse
measures, legitimate interest (Art. 6(1)(f) GDPR).
</p>
<h3>Bank transfer</h3>
<p>
Contributions are paid by ordinary bank transfer. Your bank and ours process the transfer data
under their own responsibility; on our bank statement we see the usual transfer details (your
name, account number, amount, reference). We use them only to match your contribution and are
required to retain accounting records under statutory bookkeeping and tax law (Art. 6(1)(c)
GDPR).
</p>
<h3>Access key and verification</h3>
<p>
After your transfer is matched (manually, usually within 24 hours), you receive an access key by
email. When you paste it into Drizz.li, your browser sends the key to our verification endpoint
(support.drizz.li) to check whether it is active; the response contains only the validity, tier
and expiry. The subscriber list (key, email, expiry) is stored on our server for as long as your
access is active.
</p>
<h3>Email</h3>
<p>
Transactional emails (transfer details, access key, renewals) are sent through our email
provider, Strato (Strato AG, Germany), acting as a processor. We send no newsletters or
marketing email.
</p>
<h2>4. Retention</h2>
<ul>
<li>Signup requests that are never paid are deleted after 6 months at the latest.</li>
<li>
Supporter records (key, email, expiry) are kept while your access is active and deleted within
12 months after it expires, unless you ask us to delete them sooner.
</li>
<li>
Accounting records (bank statements showing your transfer) are kept for the statutory
retention period (up to 10 years, depending on jurisdiction).
</li>
</ul>
<h2>5. Recipients</h2>
<p>
We do not sell or share personal data with third parties for their own purposes. Recipients are
limited to: Open-Meteo (weather requests made directly by your browser), our hosting provider,
our email provider (Strato), and the banks involved in your transfer.
</p>
<h2>6. Your rights</h2>
<p>
Under the GDPR you have the right to access, rectification, erasure, restriction of processing,
data portability, and to object to processing based on legitimate interest. Where processing is
based on consent, you may withdraw it at any time. To exercise any of these rights, email
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. You also have the
right to lodge a complaint with a supervisory authority, in particular in the EU member state of
your residence, or with [competent supervisory authority of the controller].
</p>
<p>
For visitors from Switzerland: the corresponding rights under the Swiss Federal Act on Data
Protection (FADP) apply equivalently.
</p>
<h2>7. Changes</h2>
<p>
We may update this policy when the service changes; the date above reflects the latest revision.
</p>
</ProsePage>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Política de privacidad" subtitle="Última actualización: 1 de agosto de 2026">
<p>
Drizz.li está hecho para necesitar los mínimos datos posibles: sin cuentas, sin cookies, sin
publicidad y sin scripts de analítica ni de seguimiento. Esta página explica el poco tratamiento
que sí ocurre: al navegar por el sitio y al apoyar el proyecto con una aportación.
</p>
<h2>1. Responsable</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
Correo electrónico:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</address>
<p>
Esta política cubre los sitios drizz.li (la app meteorológica) y support.drizz.li (la página de
registro de colaboradores).
</p>
<h2>2. Navegación por el sitio</h2>
<h3>Peticiones de datos meteorológicos</h3>
<p>
Drizz.li es un sitio estático: cuando abres un pronóstico, tu navegador obtiene los datos
directamente de las API abiertas de
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Como en
cualquier petición web, esto transmite tu dirección IP y la ubicación o término buscado a
Open-Meteo. Nosotros no recibimos ni almacenamos nada de eso. Consulta
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
>las condiciones e información de privacidad de Open-Meteo</a
>. Base jurídica: nuestro interés legítimo en entregar el contenido que solicitas (art. 6.1.f
del RGPD).
</p>
<h3>Alojamiento</h3>
<p>
El sitio estático lo sirve [proveedor de alojamiento, ubicación]. La infraestructura puede
conservar registros técnicos de corta duración (dirección IP, URL solicitada, marca de tiempo)
por seguridad y operación. Base jurídica: interés legítimo en un servicio seguro y fiable (art.
6.1.f del RGPD).
</p>
<h3>Ajustes en tu dispositivo (almacenamiento local)</h3>
<p>
Tus preferencias - tema, unidades, última ubicación buscada y (para colaboradores) tu clave de
acceso y su última verificación - se guardan únicamente en el almacenamiento local de tu
navegador. No salen de tu dispositivo salvo como se describe abajo para verificar la clave, y
puedes borrarlas cuando quieras.
</p>
<h2>3. Apoyar el proyecto (aportaciones)</h2>
<p>
Las aportaciones desbloquean los extras, así que legalmente son un acuerdo remunerado y no una
donación pura; esto es lo que tratamos para gestionarlas:
</p>
<h3>Formulario de registro</h3>
<ul>
<li>
<strong>Dirección de correo</strong> (obligatoria) - para enviarte los datos de la transferencia
y, cuando llegue tu aportación, tu clave de acceso.
</li>
<li><strong>Nombre</strong> (opcional) - para ayudar a casar tu transferencia.</li>
<li>
<strong>Moneda e importe</strong> - determinados por la configuración regional/zona horaria de tu
navegador, en tu dispositivo (no se solicita geolocalización).
</li>
<li>
<strong>Dirección IP y señales técnicas anti-bots</strong> - guardadas con la solicitud para evitar
abusos y spam.
</li>
<li>
<strong>Referencia de pago</strong> (p. ej. DRZ-XXXXXX) - generada por solicitud para casar tu transferencia
con tu registro.
</li>
</ul>
<p>
Los datos de la transferencia (cuenta y referencia) se te envían por correo en lugar de
mostrarse en la página. Bases jurídicas: ejecución del contrato (art. 6.1.b del RGPD) y, para
las medidas antiabuso, interés legítimo (art. 6.1.f del RGPD).
</p>
<h3>Transferencia bancaria</h3>
<p>
Las aportaciones se pagan por transferencia bancaria ordinaria. Tu banco y el nuestro tratan los
datos bajo su propia responsabilidad; en nuestro extracto vemos los datos habituales (nombre,
número de cuenta, importe, referencia). Solo los usamos para casar tu aportación y debemos
conservar los registros contables por obligación legal (art. 6.1.c del RGPD).
</p>
<h3>Clave de acceso y verificación</h3>
<p>
Una vez casada tu transferencia (manualmente, normalmente en 24 horas), recibes una clave de
acceso por correo. Al pegarla en Drizz.li, tu navegador envía la clave a nuestro punto de
verificación (support.drizz.li) para comprobar si está activa; la respuesta contiene solo
validez, nivel y caducidad. La lista de suscriptores (clave, correo, caducidad) se guarda en
nuestro servidor mientras tu acceso esté activo.
</p>
<h3>Correo electrónico</h3>
<p>
Los correos transaccionales (datos de transferencia, clave de acceso, renovaciones) se envían a
través de nuestro proveedor Strato (Strato AG, Alemania), que actúa como encargado del
tratamiento. No enviamos boletines ni correo comercial.
</p>
<h2>4. Conservación</h2>
<ul>
<li>Las solicitudes de registro que nunca se pagan se eliminan como máximo a los 6 meses.</li>
<li>
Los registros de colaborador (clave, correo, caducidad) se conservan mientras tu acceso esté
activo y se eliminan en los 12 meses siguientes a su expiración, salvo que pidas borrarlos
antes.
</li>
<li>
Los registros contables (extractos que muestran tu transferencia) se conservan durante el
plazo legal (hasta 10 años según la jurisdicción).
</li>
</ul>
<h2>5. Destinatarios</h2>
<p>
No vendemos ni compartimos datos personales con terceros para sus propios fines. Los
destinatarios se limitan a: Open-Meteo (peticiones meteorológicas hechas directamente por tu
navegador), nuestro proveedor de alojamiento, nuestro proveedor de correo (Strato) y los bancos
implicados en tu transferencia.
</p>
<h2>6. Tus derechos</h2>
<p>
Conforme al RGPD tienes derecho de acceso, rectificación, supresión, limitación del tratamiento,
portabilidad y oposición al tratamiento basado en el interés legítimo. Cuando el tratamiento se
base en el consentimiento, puedes retirarlo en cualquier momento. Para ejercer estos derechos,
escribe a
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. También tienes
derecho a presentar una reclamación ante una autoridad de control, en particular en el Estado
miembro de tu residencia, o ante [autoridad de control competente].
</p>
<p>
Para visitantes desde Suiza, se aplican de forma equivalente los derechos correspondientes de la
Ley Federal de Protección de Datos (LPD).
</p>
<h2>7. Cambios</h2>
<p>
Podemos actualizar esta política cuando cambie el servicio; la fecha de arriba refleja la última
revisión.
</p>
<p class="text-muted-foreground">
En caso de discrepancia prevalece la versión inglesa de esta política.
</p>
</ProsePage>
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Politique de confidentialité" subtitle="Dernière mise à jour : 1er août 2026">
<p>
Drizz.li est conçu pour avoir besoin du minimum de vos données : aucun compte, aucun cookie,
aucune publicité, aucun script d'analyse ou de suivi. Cette page explique le peu de traitement
qui a lieu - lorsque vous consultez le site et lorsque vous soutenez le projet par une
contribution.
</p>
<h2>1. Responsable du traitement</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
E-mail:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</address>
<p>
Cette politique couvre les sites drizz.li (l'application météo) et support.drizz.li (la page
d'inscription contributeur).
</p>
<h2>2. Navigation sur le site</h2>
<h3>Requêtes de données météo</h3>
<p>
Drizz.li est un site statique : lorsque vous ouvrez une prévision, votre navigateur récupère les
données directement auprès des API ouvertes d'
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Comme pour toute
requête web, cela transmet votre adresse IP et le lieu ou le terme recherché à Open-Meteo. Nous
n'en recevons ni n'en conservons rien. Voir
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
>les conditions et informations de confidentialité d'Open-Meteo</a
>. Base légale : notre intérêt légitime à fournir le contenu demandé (art. 6, par. 1, point f)
du RGPD).
</p>
<h3>Hébergement</h3>
<p>
Le site statique est servi par [hébergeur, localisation]. L'infrastructure d'hébergement peut
conserver de brefs journaux techniques (adresse IP, URL demandée, horodatage) à des fins de
sécurité et d'exploitation. Base légale : intérêt légitime à un service sûr et fiable (art. 6,
par. 1, point f) du RGPD).
</p>
<h3>Réglages sur votre appareil (stockage local)</h3>
<p>
Vos préférences - thème, unités, dernier lieu recherché et (pour les contributeurs) votre clé
d'accès et son dernier résultat de vérification - sont stockées uniquement dans le stockage
local de votre navigateur. Elles ne quittent pas votre appareil, sauf comme décrit ci-dessous
pour la vérification de la clé, et vous pouvez les effacer à tout moment.
</p>
<h2>3. Soutenir le projet (contributions)</h2>
<p>
Les contributions débloquent les bonus : juridiquement il s'agit donc d'un accord payant et non
d'un simple don - voici ce que nous traitons pour les gérer :
</p>
<h3>Formulaire d'inscription</h3>
<ul>
<li>
<strong>Adresse e-mail</strong> (obligatoire) - pour vous envoyer les coordonnées bancaires puis,
à réception de votre contribution, votre clé d'accès.
</li>
<li><strong>Nom</strong> (facultatif) - pour faciliter le rapprochement de votre virement.</li>
<li>
<strong>Devise et montant</strong> - déterminés d'après la langue/le fuseau horaire de votre navigateur,
sur votre appareil (aucune demande de géolocalisation).
</li>
<li>
<strong>Adresse IP et signaux anti-robots techniques</strong> - conservés avec la demande pour prévenir
les abus et le spam.
</li>
<li>
<strong>Référence de paiement</strong> (p. ex. DRZ-XXXXXX) - générée par demande pour rapprocher
votre virement de votre inscription.
</li>
</ul>
<p>
Les coordonnées bancaires (compte et référence) vous sont envoyées par e-mail plutôt
qu'affichées sur la page. Bases légales : exécution du contrat (art. 6, par. 1, point b) du
RGPD) et, pour les mesures anti-abus, intérêt légitime (art. 6, par. 1, point f) du RGPD).
</p>
<h3>Virement bancaire</h3>
<p>
Les contributions sont réglées par virement bancaire ordinaire. Votre banque et la nôtre
traitent les données du virement sous leur propre responsabilité ; sur notre relevé figurent les
informations habituelles (nom, numéro de compte, montant, référence). Nous ne les utilisons que
pour rapprocher votre contribution et devons conserver les pièces comptables au titre des
obligations légales (art. 6, par. 1, point c) du RGPD).
</p>
<h3>Clé d'accès et vérification</h3>
<p>
Une fois votre virement rapproché (manuellement, en général sous 24 heures), vous recevez une
clé d'accès par e-mail. Lorsque vous la collez dans Drizz.li, votre navigateur envoie la clé à
notre point de vérification (support.drizz.li) pour savoir si elle est active ; la réponse ne
contient que la validité, le niveau et l'échéance. La liste des abonnés (clé, e-mail, échéance)
est conservée sur notre serveur tant que votre accès est actif.
</p>
<h3>E-mail</h3>
<p>
Les e-mails transactionnels (coordonnées bancaires, clé d'accès, renouvellements) sont envoyés
via notre prestataire Strato (Strato AG, Allemagne), agissant comme sous-traitant. Nous
n'envoyons ni newsletter ni e-mail marketing.
</p>
<h2>4. Conservation</h2>
<ul>
<li>Les demandes d'inscription jamais payées sont supprimées au plus tard après 6 mois.</li>
<li>
Les données contributeur (clé, e-mail, échéance) sont conservées tant que votre accès est
actif et supprimées dans les 12 mois suivant son expiration, sauf demande de suppression
anticipée.
</li>
<li>
Les pièces comptables (relevés bancaires mentionnant votre virement) sont conservées pendant
la durée légale (jusqu'à 10 ans selon la juridiction).
</li>
</ul>
<h2>5. Destinataires</h2>
<p>
Nous ne vendons ni ne partageons de données personnelles avec des tiers pour leurs propres
finalités. Les destinataires se limitent à : Open-Meteo (requêtes météo effectuées directement
par votre navigateur), notre hébergeur, notre prestataire e-mail (Strato) et les banques
impliquées dans votre virement.
</p>
<h2>6. Vos droits</h2>
<p>
En vertu du RGPD, vous disposez d'un droit d'accès, de rectification, d'effacement, de
limitation du traitement, de portabilité, et d'opposition au traitement fondé sur l'intérêt
légitime. Lorsque le traitement repose sur le consentement, vous pouvez le retirer à tout
moment. Pour exercer ces droits, écrivez à
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. Vous avez
également le droit d'introduire une réclamation auprès d'une autorité de contrôle, notamment
dans l'État membre de votre résidence, ou auprès de [autorité de contrôle compétente].
</p>
<p>
Pour les visiteurs suisses, les droits correspondants de la loi fédérale sur la protection des
données (LPD) s'appliquent de manière équivalente.
</p>
<h2>7. Modifications</h2>
<p>
Nous pouvons mettre à jour cette politique lorsque le service évolue ; la date ci-dessus indique
la dernière révision.
</p>
<p class="text-muted-foreground">
En cas de divergence, la version anglaise de cette politique fait foi.
</p>
</ProsePage>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
</script>
<ProsePage title="Informativa sulla privacy" subtitle="Ultimo aggiornamento: 1 agosto 2026">
<p>
Drizz.li è costruito per aver bisogno del minimo dei tuoi dati: nessun account, nessun cookie,
nessuna pubblicità, nessuno script di analisi o tracciamento. Questa pagina spiega il poco
trattamento che avviene comunque: quando navighi sul sito e quando sostieni il progetto con un
contributo.
</p>
<h2>1. Titolare del trattamento</h2>
<address>
Vincent van der Wal<br />
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
E-mail:
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
</address>
<p>
Questa informativa copre i siti drizz.li (l'app meteo) e support.drizz.li (la pagina di
iscrizione per i sostenitori).
</p>
<h2>2. Navigazione sul sito</h2>
<h3>Richieste di dati meteo</h3>
<p>
Drizz.li è un sito statico: quando apri una previsione, il tuo browser scarica i dati
direttamente dalle API aperte di
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Come per
qualsiasi richiesta web, questo trasmette il tuo indirizzo IP e la località o il termine cercato
a Open-Meteo. Noi non riceviamo né conserviamo nulla di tutto ciò. Vedi
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
>le condizioni e le informazioni sulla privacy di Open-Meteo</a
>. Base giuridica: il nostro legittimo interesse a fornire il contenuto richiesto (art. 6, par.
1, lett. f del GDPR).
</p>
<h3>Hosting</h3>
<p>
Il sito statico è servito da [fornitore di hosting, sede]. L'infrastruttura può conservare brevi
log tecnici del server (indirizzo IP, URL richiesto, data e ora) per sicurezza e gestione. Base
giuridica: legittimo interesse a un servizio sicuro e affidabile (art. 6, par. 1, lett. f del
GDPR).
</p>
<h3>Impostazioni sul tuo dispositivo (archiviazione locale)</h3>
<p>
Le tue preferenze - tema, unità di misura, ultima località cercata e (per i sostenitori) la
chiave di accesso e l'ultimo esito della verifica - sono salvate solo nell'archiviazione locale
del browser. Non lasciano il tuo dispositivo, salvo quanto descritto sotto per la verifica della
chiave, e puoi cancellarle quando vuoi.
</p>
<h2>3. Sostenere il progetto (contributi)</h2>
<p>
I contributi sbloccano gli extra, quindi giuridicamente sono un accordo a pagamento e non una
pura donazione: ecco cosa trattiamo per gestirli:
</p>
<h3>Modulo di iscrizione</h3>
<ul>
<li>
<strong>Indirizzo e-mail</strong> (obbligatorio) - per inviarti i dati del bonifico e, all'arrivo
del contributo, la chiave di accesso.
</li>
<li><strong>Nome</strong> (facoltativo) - per facilitare l'abbinamento del bonifico.</li>
<li>
<strong>Valuta e importo</strong> - determinati dalle impostazioni locali/fuso orario del browser,
sul tuo dispositivo (nessuna richiesta di geolocalizzazione).
</li>
<li>
<strong>Indirizzo IP e segnali tecnici anti-bot</strong> - conservati con la richiesta per prevenire
abusi e spam.
</li>
<li>
<strong>Riferimento di pagamento</strong> (es. DRZ-XXXXXX) - generato per ogni richiesta per abbinare
il bonifico all'iscrizione.
</li>
</ul>
<p>
I dati del bonifico (conto e riferimento) ti vengono inviati via e-mail anziché mostrati sulla
pagina. Basi giuridiche: esecuzione del contratto (art. 6, par. 1, lett. b del GDPR) e, per le
misure anti-abuso, legittimo interesse (art. 6, par. 1, lett. f del GDPR).
</p>
<h3>Bonifico bancario</h3>
<p>
I contributi si pagano con un normale bonifico. La tua banca e la nostra trattano i dati del
bonifico sotto la propria responsabilità; sul nostro estratto conto vediamo i dati consueti
(nome, numero di conto, importo, riferimento). Li usiamo solo per abbinare il contributo e siamo
tenuti a conservare le scritture contabili per obbligo di legge (art. 6, par. 1, lett. c del
GDPR).
</p>
<h3>Chiave di accesso e verifica</h3>
<p>
Dopo l'abbinamento del bonifico (manuale, di solito entro 24 ore) ricevi una chiave di accesso
via e-mail. Quando la incolli in Drizz.li, il browser invia la chiave al nostro endpoint di
verifica (support.drizz.li) per controllare se è attiva; la risposta contiene solo validità,
livello e scadenza. L'elenco dei sostenitori (chiave, e-mail, scadenza) resta sul nostro server
finché il tuo accesso è attivo.
</p>
<h3>E-mail</h3>
<p>
Le e-mail transazionali (dati del bonifico, chiave di accesso, rinnovi) sono inviate tramite il
nostro fornitore Strato (Strato AG, Germania), che agisce come responsabile del trattamento. Non
inviamo newsletter né e-mail di marketing.
</p>
<h2>4. Conservazione</h2>
<ul>
<li>Le richieste di iscrizione mai pagate sono cancellate al più tardi dopo 6 mesi.</li>
<li>
I dati dei sostenitori (chiave, e-mail, scadenza) sono conservati finché l'accesso è attivo e
cancellati entro 12 mesi dalla scadenza, salvo richiesta di cancellazione anticipata.
</li>
<li>
Le scritture contabili (estratti conto con il tuo bonifico) sono conservate per il periodo di
legge (fino a 10 anni a seconda della giurisdizione).
</li>
</ul>
<h2>5. Destinatari</h2>
<p>
Non vendiamo né condividiamo dati personali con terzi per finalità proprie. I destinatari si
limitano a: Open-Meteo (richieste meteo fatte direttamente dal tuo browser), il nostro fornitore
di hosting, il nostro fornitore e-mail (Strato) e le banche coinvolte nel bonifico.
</p>
<h2>6. I tuoi diritti</h2>
<p>
In base al GDPR hai diritto di accesso, rettifica, cancellazione, limitazione del trattamento,
portabilità dei dati e opposizione al trattamento fondato sul legittimo interesse. Se il
trattamento si basa sul consenso, puoi revocarlo in qualsiasi momento. Per esercitare questi
diritti scrivi a
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. Hai inoltre il
diritto di proporre reclamo a un'autorità di controllo, in particolare nello Stato membro di
residenza, o a [autorità di controllo competente].
</p>
<p>
Per i visitatori dalla Svizzera valgono in modo equivalente i corrispondenti diritti previsti
dalla Legge federale sulla protezione dei dati (LPD).
</p>
<h2>7. Modifiche</h2>
<p>
Possiamo aggiornare questa informativa quando il servizio cambia; la data in alto indica
l'ultima revisione.
</p>
<p class="text-muted-foreground">
In caso di discrepanza prevale la versione inglese di questa informativa.
</p>
</ProsePage>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import LocalizedContent from '$lib/components/localized-content.svelte';
import de from './content/de.svelte';
import en from './content/en.svelte';
import es from './content/es.svelte';
import fr from './content/fr.svelte';
import it from './content/it.svelte';
</script>
<LocalizedContent variants={{ en, de, es, fr, it }} />
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Unterstützerbedingungen" subtitle="Zuletzt aktualisiert: 1. August 2026">
<p>
Drizz.li ist kostenlos nutzbar. Diese Bedingungen betreffen den freiwilligen
Unterstützerbeitrag: eine kleine Zahlung, die das Projekt am Laufen hält und als Dankeschön die
Unterstützer-Extras freischaltet. Auch wenn wir von einem Beitrag sprechen, werden dafür
Funktionen freigeschaltet.
</p>
<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 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>
<ul>
<li>
Sie fordern die Überweisungsdaten auf der Anmeldeseite an; wir senden sie Ihnen zusammen mit
einer persönlichen Zahlungsreferenz per E-Mail.
</li>
<li>
Sie überweisen den Betrag (ab 3 € / 3 $ / 3 CHF pro Monat) per gewöhnlicher Banküberweisung
unter Angabe der Referenz.
</li>
<li>
Überweisungen werden manuell zugeordnet. Sobald Ihr Beitrag eingeht - meist innerhalb von 24
Stunden, höchstens einige Tage - erhalten Sie per E-Mail einen persönlichen Zugangsschlüssel.
</li>
<li>
Sie fügen den Schlüssel einmal in Drizz.li ein; er wird auf Ihrem Gerät gespeichert und
automatisch überprüft.
</li>
</ul>
<h2>3. Keine automatische Verlängerung</h2>
<p>
Beiträge sind einmalig und im Voraus bezahlt: Nichts verlängert sich automatisch und wir
belasten Sie nie. Läuft Ihr Zeitraum ab, sperren sich die Extras einfach wieder; ein erneuter
Beitrag mit derselben E-Mail verlängert Ihren bestehenden Schlüssel.
</p>
<h2>4. Widerruf und Erstattung</h2>
<p>
Als Verbraucherin oder Verbraucher in der EU/im EWR haben Sie ein gesetzliches 14-tägiges
Widerrufsrecht. Darüber hinaus halten wir es einfach: Sind Sie innerhalb von 14 Tagen nach
Erhalt Ihres Zugangsschlüssels aus irgendeinem Grund unzufrieden, schreiben Sie an
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
und wir erstatten Ihren Beitrag vollständig.
</p>
<h2>5. Faire Nutzung</h2>
<p>
Der Zugangsschlüssel ist persönlich. Bitte veröffentlichen oder teilen Sie ihn nicht; eindeutig
missbrauchte Schlüssel (z. B. öffentlich geteilt) können gesperrt werden. Wird Ihr Schlüssel
ohne Grund gesperrt, haben Sie Anspruch auf eine anteilige Erstattung.
</p>
<h2>6. Dienst und Verfügbarkeit</h2>
<p>
Drizz.li ist ein persönliches Open-Source-Projekt und wird ohne Gewähr bereitgestellt. Wir
bemühen uns um Verfügbarkeit und Genauigkeit, können aber weder unterbrechungsfreien Betrieb
noch die Richtigkeit der Vorhersagen garantieren - Wetterdaten sind rein informativ (siehe
Haftungsausschluss im Impressum). Sollten die Unterstützer-Extras während eines bezahlten
Zeitraums dauerhaft ausfallen, erstatten wir auf Anfrage den Restzeitraum.
</p>
<h2>7. Daten</h2>
<p>
Wie wir Ihre Daten (E-Mail, Zahlungsreferenz, Überweisungsdaten) verarbeiten, steht in der
<a href={href('/legal/privacy')}>Datenschutzerklärung</a>.
</p>
<h2>8. Anwendbares Recht</h2>
<p>
Für diese Bedingungen gilt das Recht der [Schweiz], unbeschadet zwingender
Verbraucherschutzvorschriften Ihres Wohnsitzlandes.
</p>
<p class="text-muted-foreground">Massgeblich ist die englische Fassung dieser Bedingungen.</p>
</ProsePage>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Supporter terms" subtitle="Last updated: 1 August 2026">
<p>
Drizz.li is free to use. These terms cover the optional supporter contribution: a small payment
that helps keep the project running and, as a thank-you, unlocks the supporter extras. Although
we call it a contribution, features are unlocked in return.
</p>
<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 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>
<ul>
<li>
You request the transfer details on the signup page; we email them to you together with a
personal payment reference.
</li>
<li>
You transfer the amount (from €3 / $3 / CHF 3 per month) by ordinary bank transfer, including
the reference.
</li>
<li>
Transfers are matched manually. Once your contribution arrives - usually within 24 hours, at
most a few days - you receive a personal access key by email.
</li>
<li>
You paste the key into Drizz.li once; it is stored on your device and verified automatically.
</li>
</ul>
<h2>3. No auto-renewal</h2>
<p>
Contributions are one-off and prepaid: nothing renews automatically and we never charge you.
When your period ends, the extras simply lock again; contributing again with the same email
extends your existing key.
</p>
<h2>4. Withdrawal and refunds</h2>
<p>
If you are a consumer in the EU/EEA, you have a statutory 14-day right of withdrawal. Beyond
that, we keep it simple: if you are unhappy for any reason within 14 days of receiving your
access key, email
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a> and we will refund your
contribution in full.
</p>
<h2>5. Fair use</h2>
<p>
The access key is personal. Please don't publish or share it; keys that are clearly abused (e.g.
shared publicly) may be revoked. If your key is revoked without cause, you are entitled to a
pro-rata refund.
</p>
<h2>6. Service and availability</h2>
<p>
Drizz.li is a personal open-source project, provided as-is. We work to keep it available and
accurate, but we cannot guarantee uninterrupted availability or the correctness of forecasts -
weather data is informational only (see the disclaimer in the imprint). If the supporter extras
become permanently unavailable during a period you paid for, we will refund the remaining period
on request.
</p>
<h2>7. Data</h2>
<p>
How we handle your data (email, payment reference, bank transfer details) is described in the
<a href={href('/legal/privacy')}>privacy policy</a>.
</p>
<h2>8. Governing law</h2>
<p>
These terms are governed by the law of [Switzerland], without prejudice to mandatory consumer
protection provisions of your country of residence.
</p>
</ProsePage>
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage
title="Condiciones para colaboradores"
subtitle="Última actualización: 1 de agosto de 2026"
>
<p>
Drizz.li es gratuito. Estas condiciones cubren la aportación voluntaria de colaborador: un
pequeño pago que ayuda a mantener el proyecto y que, como agradecimiento, desbloquea los extras.
Aunque lo llamamos aportación, a cambio se desbloquean funciones.
</p>
<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 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>
<ul>
<li>
Solicitas los datos de la transferencia en la página de registro; te los enviamos por correo
junto con una referencia de pago personal.
</li>
<li>
Transfieres el importe (desde 3 € / 3 $ / 3 CHF al mes) mediante una transferencia bancaria
ordinaria, incluyendo la referencia.
</li>
<li>
Las transferencias se concilian manualmente. Cuando llega tu aportación - normalmente en 24
horas, como mucho unos días - recibes por correo una clave de acceso personal.
</li>
<li>
Pegas la clave en Drizz.li una vez; se guarda en tu dispositivo y se verifica automáticamente.
</li>
</ul>
<h2>3. Sin renovación automática</h2>
<p>
Las aportaciones son únicas y prepagadas: nada se renueva solo y nunca te cobramos. Cuando
termina tu periodo, los extras simplemente se bloquean de nuevo; volver a aportar con el mismo
correo amplía tu clave existente.
</p>
<h2>4. Desistimiento y reembolsos</h2>
<p>
Si eres consumidor en la UE/EEE, dispones de un derecho legal de desistimiento de 14 días. Más
allá de eso, lo mantenemos sencillo: si no estás satisfecho por cualquier motivo en los 14 días
siguientes a recibir tu clave, escribe a
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
y te reembolsaremos la aportación íntegra.
</p>
<h2>5. Uso razonable</h2>
<p>
La clave de acceso es personal. No la publiques ni la compartas; las claves claramente abusadas
(por ejemplo, compartidas públicamente) pueden revocarse. Si tu clave se revoca sin causa,
tienes derecho a un reembolso proporcional.
</p>
<h2>6. Servicio y disponibilidad</h2>
<p>
Drizz.li es un proyecto personal de código abierto, ofrecido tal cual. Trabajamos para
mantenerlo disponible y preciso, pero no podemos garantizar disponibilidad ininterrumpida ni la
exactitud de los pronósticos: los datos meteorológicos son solo informativos (véase el descargo
del aviso legal). Si los extras dejan de estar disponibles de forma permanente durante un
periodo que has pagado, reembolsaremos el periodo restante a petición.
</p>
<h2>7. Datos</h2>
<p>
Cómo tratamos tus datos (correo, referencia de pago, datos de la transferencia) se describe en
la
<a href={href('/legal/privacy')}>política de privacidad</a>.
</p>
<h2>8. Legislación aplicable</h2>
<p>
Estas condiciones se rigen por la legislación de [Suiza], sin perjuicio de las disposiciones
imperativas de protección al consumidor de tu país de residencia.
</p>
<p class="text-muted-foreground">
En caso de discrepancia prevalece la versión inglesa de estas condiciones.
</p>
</ProsePage>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Conditions contributeur" subtitle="Dernière mise à jour : 1er août 2026">
<p>
Drizz.li est gratuit. Ces conditions portent sur la contribution facultative : un petit paiement
qui aide à faire vivre le projet et qui, en remerciement, débloque les bonus. Même si nous
parlons de contribution, des fonctionnalités sont débloquées en retour.
</p>
<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 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>
<ul>
<li>
Vous demandez les coordonnées bancaires sur la page d'inscription ; nous vous les envoyons par
e-mail avec une référence de paiement personnelle.
</li>
<li>
Vous virez le montant (à partir de 3 € / 3 $ / 3 CHF par mois) par virement bancaire
ordinaire, en indiquant la référence.
</li>
<li>
Les virements sont rapprochés manuellement. Dès réception de votre contribution - en général
sous 24 heures, au plus quelques jours - vous recevez une clé d'accès personnelle par e-mail.
</li>
<li>
Vous collez la clé dans Drizz.li une seule fois ; elle est stockée sur votre appareil et
vérifiée automatiquement.
</li>
</ul>
<h2>3. Pas de reconduction automatique</h2>
<p>
Les contributions sont ponctuelles et prépayées : rien ne se reconduit et nous ne vous prélevons
jamais. À la fin de votre période, les bonus se reverrouillent simplement ; contribuer à nouveau
avec la même adresse prolonge votre clé existante.
</p>
<h2>4. Rétractation et remboursement</h2>
<p>
Si vous êtes consommateur dans l'UE/EEE, vous disposez d'un droit légal de rétractation de 14
jours. Au-delà, nous faisons simple : si vous n'êtes pas satisfait, pour quelque raison que ce
soit, dans les 14 jours suivant la réception de votre clé, écrivez à
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
et nous vous remboursons intégralement.
</p>
<h2>5. Usage loyal</h2>
<p>
La clé d'accès est personnelle. Merci de ne pas la publier ni la partager ; les clés
manifestement détournées (par exemple partagées publiquement) peuvent être révoquées. Si votre
clé est révoquée sans motif, vous avez droit à un remboursement au prorata.
</p>
<h2>6. Service et disponibilité</h2>
<p>
Drizz.li est un projet personnel open source, fourni en l'état. Nous nous efforçons de le
maintenir disponible et exact, mais nous ne pouvons garantir ni une disponibilité ininterrompue
ni l'exactitude des prévisions - les données météo sont purement informatives (voir
l'avertissement dans les mentions légales). Si les bonus devenaient définitivement indisponibles
pendant une période payée, nous rembourserions la période restante sur demande.
</p>
<h2>7. Données</h2>
<p>
La façon dont nous traitons vos données (e-mail, référence de paiement, informations de
virement) est décrite dans la
<a href={href('/legal/privacy')}>politique de confidentialité</a>.
</p>
<h2>8. Droit applicable</h2>
<p>
Ces conditions sont régies par le droit de la [Suisse], sans préjudice des dispositions
impératives de protection des consommateurs de votre pays de résidence.
</p>
<p class="text-muted-foreground">
En cas de divergence, la version anglaise de ces conditions fait foi.
</p>
</ProsePage>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import ProsePage from '$lib/components/prose-page.svelte';
import { href } from '$lib/i18n';
</script>
<ProsePage title="Condizioni per i sostenitori" subtitle="Ultimo aggiornamento: 1 agosto 2026">
<p>
Drizz.li è gratuito. Queste condizioni riguardano il contributo facoltativo di sostegno: un
piccolo pagamento che aiuta a mantenere vivo il progetto e che, come ringraziamento, sblocca gli
extra. Anche se lo chiamiamo contributo, in cambio vengono sbloccate delle funzioni.
</p>
<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 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>
<ul>
<li>
Richiedi i dati per il bonifico nella pagina di iscrizione; te li inviamo via e-mail insieme a
un riferimento di pagamento personale.
</li>
<li>
Effettui il bonifico (da 3 € / 3 $ / 3 CHF al mese) con un normale bonifico bancario,
indicando il riferimento.
</li>
<li>
I bonifici vengono abbinati manualmente. Appena il contributo arriva - di solito entro 24 ore,
al massimo pochi giorni - ricevi via e-mail una chiave di accesso personale.
</li>
<li>
Incolli la chiave in Drizz.li una volta sola; viene salvata sul tuo dispositivo e verificata
automaticamente.
</li>
</ul>
<h2>3. Nessun rinnovo automatico</h2>
<p>
I contributi sono una tantum e prepagati: nulla si rinnova da solo e non ti addebitiamo mai
nulla. Alla fine del periodo gli extra si bloccano semplicemente di nuovo; un nuovo contributo
con la stessa e-mail estende la chiave esistente.
</p>
<h2>4. Recesso e rimborsi</h2>
<p>
Se sei un consumatore nell'UE/SEE, hai un diritto di recesso legale di 14 giorni. Oltre a
questo, la teniamo semplice: se per qualsiasi motivo non sei soddisfatto entro 14 giorni dalla
ricezione della chiave, scrivi a
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
e ti rimborseremo l'intero contributo.
</p>
<h2>5. Uso corretto</h2>
<p>
La chiave di accesso è personale. Non pubblicarla né condividerla; le chiavi chiaramente abusate
(ad esempio condivise pubblicamente) possono essere revocate. Se la tua chiave viene revocata
senza motivo, hai diritto a un rimborso proporzionale.
</p>
<h2>6. Servizio e disponibilità</h2>
<p>
Drizz.li è un progetto personale open source, fornito così com'è. Lavoriamo per mantenerlo
disponibile e accurato, ma non possiamo garantire né la continuità del servizio né la
correttezza delle previsioni: i dati meteo sono solo informativi (vedi l'avvertenza nelle note
legali). Se gli extra dovessero diventare permanentemente non disponibili durante un periodo
pagato, rimborseremo su richiesta il periodo residuo.
</p>
<h2>7. Dati</h2>
<p>
Il trattamento dei tuoi dati (e-mail, riferimento di pagamento, dati del bonifico) è descritto
nell'
<a href={href('/legal/privacy')}>informativa sulla privacy</a>.
</p>
<h2>8. Legge applicabile</h2>
<p>
Queste condizioni sono regolate dal diritto della [Svizzera], fatte salve le disposizioni
imperative di tutela del consumatore del tuo paese di residenza.
</p>
<p class="text-muted-foreground">
In caso di discrepanza prevale la versione inglese di queste condizioni.
</p>
</ProsePage>
+1 -1
View File
@@ -8,6 +8,6 @@ describe('/+page.svelte', () => {
render(Page);
const title = document.querySelector('title');
expect(title?.textContent).toBe('Drizzli');
expect(title?.textContent).toBe('Drizz.li');
});
});
+77 -1
View File
@@ -1,9 +1,85 @@
<script lang="ts">
import { setContext } from 'svelte';
import { page } from '$app/stores';
import { storedLocation } from '$lib/stores/settings';
import { routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import type { Snippet } from 'svelte';
interface Props {
children?: import('svelte').Snippet;
children?: Snippet;
}
let { children }: Props = $props();
// The location heading lives in the layout, not in each page: a layout
// survives navigation, so switching between forecasts no longer tears the
// heading down and rebuilds it once the next page's data has loaded.
// Page-specific controls (model pickers, range buttons) render into the same
// row through this context.
let actions = $state<Snippet | null>(null);
setContext('weather-hero', {
setActions: (snippet: Snippet | null) => {
actions = snippet;
}
});
// Page data wins; the persisted store covers the moment before the first
// load resolves (and any weather page that doesn't carry a location).
let location = $derived($page.data.location ?? $storedLocation);
const SUBTITLES: [string, () => string][] = [
['/weather/week', m.page_week_subtitle],
['/weather/compare', m.page_compare_subtitle],
['/weather/14-day', m.page_14day_subtitle],
['/weather/seasonal', m.page_seasonal_subtitle],
['/weather/historical', m.page_historical_subtitle]
];
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}
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="flex min-w-0 items-center gap-3">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country ?? ''}
/>
<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>
{#if region}
<p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
{/if}
</div>
</div>
{#if actions}{@render actions()}{/if}
</div>
{/if}
{@render children?.()}
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { localizeHref } from '$lib/paraglide/runtime';
// Same reasoning as the root redirect: resolved in the browser so the
// visitor's language is known (see routes/+page.svelte).
onMount(() => {
goto(localizeHref('/weather/week/'), { replaceState: true });
});
</script>
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async () => {
throw redirect(303, '/weather/week/');
}) satisfies PageLoad;
+3 -2
View File
@@ -3,18 +3,19 @@
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender
onMount(() => {
goto(
resolve('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
href('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{
replaceState: true
}
@@ -1,20 +1,29 @@
<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 { 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';
import { Switch } from '$lib/components/ui/switch';
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import {
type DaylightBand,
type EnsembleForecastResult,
fetchEnsembleForecast
} from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, ensembleModelGroups } from '../../options';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
@@ -36,6 +45,11 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null || loadError != null);
useHeroActions(heroActions);
// the URL is the source of truth: location comes from the load function,
// which is also correct on hydrated prerendered pages. The persisted store
// only mirrors it so the header and bare /weather/* redirects follow along.
@@ -44,17 +58,12 @@
storedLocation.set(data.location);
});
const DEFAULT_MODEL = 'ncep_gefs_seamless';
let params = $state({
...defaultParameters,
hourly: [
'temperature_2m',
'precipitation',
'wind_speed_10m',
'relative_humidity_2m',
'cloud_cover',
'pressure_msl'
],
models: ['ncep_gefs_seamless']
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
models: [DEFAULT_MODEL]
});
// units live in a persisted store; mirror them into params so a change
@@ -79,11 +88,19 @@
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
// preselect the persisted ensemble model (client-only, keeps SSR stable)
params.models = [get(storedEnsembleModel)];
// a shared link carries its model; otherwise fall back to the stored choice
const fromUrl = get(page).url.searchParams.get('model');
params.models = [fromUrl || get(storedEnsembleModel)];
mounted = true;
});
// keep the plotted ensemble in the URL
$effect(() => {
const model = params.models?.[0];
if (!mounted || !model) return;
syncSearchParams({ model: unlessDefault(model, DEFAULT_MODEL) });
});
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
@@ -279,40 +296,20 @@
});
</script>
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="flex min-w-0 items-center gap-3">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country ?? ''}
/>
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
</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
>14-day ensemble forecast
</p>
</div>
</div>
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<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="Ensemble model"
label={m.model_ensemble()}
onModelChange={(model) => {
params.models = [model];
storedEnsembleModel.set(model);
}}
/>
</div>
</div>
{/snippet}
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
@@ -334,8 +331,7 @@
/>
</svg>
<span>
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead — the spread is
trimmed to its available range.
{m.ensemble_trimmed({ days: validDays })}
</span>
</div>
{/if}
@@ -348,29 +344,52 @@
</div>
{/if}
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300} bleed={false}>
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<div class="relative">
{#if fetchedData}
{#each chartDefs as def, i (i)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
title={def.title}
subtitle={def.subtitle}
showCredit={def.showCredit}
zeroBaseLeft={def.zeroBaseLeft ?? true}
yMin={def.yMin}
yMax={def.yMax}
{showLegend}
height={300}
group={CHART_GROUP}
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
the page margins (padded), the graphs bleed to the edges -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
{#each chartDefs as def, i (i)}
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
<div class="mb-1 px-3 lg:px-0">
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
{#if def.subtitle}
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
{/if}
</div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
showCredit={def.showCredit}
zeroBaseLeft={def.zeroBaseLeft ?? true}
yMin={def.yMin}
yMax={def.yMax}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div>
{/each}
</div>
{:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<div in:fade={{ duration: 200 }} out:skeletonOut>
<ChartContainer
loading
chartCount={params.hourly?.length || 1}
chartHeight={340}
bleed={false}
/>
{/each}
</div>
{/if}
</ChartContainer>
</div>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
@@ -379,7 +398,9 @@
{#snippet controls()}
<div class="flex items-center gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
<Label for="show_legend" class="cursor-pointer text-base leading-none"
>{m.legend_show()}</Label
>
</div>
{/snippet}
</ChartToolbar>
+3 -2
View File
@@ -3,18 +3,19 @@
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender
onMount(() => {
goto(
resolve('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
href('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{
replaceState: true
}
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,152 +1,774 @@
<script lang="ts">
import { formatZoned, getZonedHour } from '$lib/utils/date';
import { onDestroy, onMount } from 'svelte';
import { getWeatherIconName } from '../../utils/weather-codes';
import { formatZoned } from '$lib/utils/date';
import {
groupHover,
groupRange,
registerGroupMember,
setGroupHover,
setGroupRange
} from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { getWeatherIconName, hasWeatherIcon } from '../../utils/weather-codes';
import { modelColor, modelLabel } from './comparison';
import type { ModelSeriesData } from '$lib/services/weather';
interface Props {
timestamps: number[];
hourlyFlat: Record<string, number[]>;
models: string[];
models: ModelSeriesData[];
modelOrder: string[];
sunrise: number[];
sunset: number[];
timezone: string;
group: string;
plotInsetLeft: number;
plotInsetRight: number;
showModelNames: boolean;
onToggleModelNames: () => void;
registerExporter?: (exporter: TimelineExporter | null) => void;
}
let { timestamps, hourlyFlat, models, sunrise, sunset, timezone }: Props = $props();
interface TimelineExporter {
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
}
let hourlyInterval = $state<1 | 3>(3);
interface TimelinePoint {
index: number;
time: number;
x: number;
hour: string;
date: string;
}
let filteredIndices = $derived(
timestamps.reduce<number[]>((acc, ts, i) => {
if (hourlyInterval === 1 || getZonedHour(new Date(ts), timezone) % 3 === 0) {
acc.push(i);
}
return acc;
}, [])
interface DaySeparator {
x: number;
}
interface SunlightSegment {
x1: number;
x2: number;
}
interface TooltipRow {
modelId: string;
label: string;
color: string;
condition: string;
cloudCover: number | null;
}
let {
timestamps,
models,
modelOrder,
sunrise,
sunset,
timezone,
group,
plotInsetLeft,
plotInsetRight,
showModelNames,
onToggleModelNames,
registerExporter
}: Props = $props();
const HOUR = 3600;
const MIN_SPAN = 2 * HOUR;
const HEADER_H = 38;
const ROW_H = 38;
const FOOTER_H = 5;
const ICON_SIZE = 20;
const ICON_BOX_WIDTH = 20;
const ICON_BOX_HEIGHT = 30;
const MIN_ICON_GAP = 20;
const SUNLIGHT_STRIP_H = 4;
const EXPORT_TITLE_H = 30;
const SAMPLE_STEPS = [1, 2, 3, 4, 6, 8, 12, 24] as const;
let containerEl: HTMLDivElement | undefined = $state();
let canvasEl: HTMLCanvasElement | undefined = $state();
let width = $state(0);
let themeVersion = $state(0);
let dragSelect = $state<{ x0: number; x1: number } | null>(null);
let screenRenderVersion = 0;
let timestampsSec = $derived(timestamps.map((timestamp) => timestamp / 1000));
let displayModels = $derived(
models.filter((model) => model.variables.weather_code?.some(hasWeatherIcon))
);
let sharedRange = $derived(groupRange(group));
let sharedHover = $derived(groupHover(group));
let tMin = $derived(timestampsSec[0] ?? 0);
let tMax = $derived(
timestampsSec.length > 1 ? timestampsSec[timestampsSec.length - 1] : tMin + HOUR
);
let viewStart = $derived(sharedRange ? Math.max(tMin, sharedRange.start) : tMin);
let viewEnd = $derived(
sharedRange ? Math.max(viewStart + MIN_SPAN / 2, Math.min(tMax, sharedRange.end)) : tMax
);
let zoomed = $derived(sharedRange !== null && viewEnd - viewStart < tMax - tMin);
let plotWidth = $derived(Math.max(1, width - plotInsetLeft - plotInsetRight));
let height = $derived(HEADER_H + displayModels.length * ROW_H + FOOTER_H);
function xForTime(time: number): number {
return plotInsetLeft + ((time - viewStart) / Math.max(1, viewEnd - viewStart)) * plotWidth;
}
function timeForX(x: number): number {
return viewStart + ((x - plotInsetLeft) / Math.max(1, plotWidth)) * (viewEnd - viewStart);
}
function clampPlotX(x: number): number {
return Math.max(plotInsetLeft, Math.min(width - plotInsetRight, x));
}
function applyRange(start: number, end: number): void {
const fullSpan = tMax - tMin;
const span = Math.min(Math.max(end - start, MIN_SPAN), fullSpan);
if (span >= fullSpan) {
setGroupRange(group, null);
return;
}
const boundedStart = Math.max(tMin, Math.min(start, tMax - span));
setGroupRange(group, { start: boundedStart, end: boundedStart + span });
}
function zoomAt(centerTime: number, factor: number): void {
const span = viewEnd - viewStart;
const nextSpan = span * factor;
const fraction = (centerTime - viewStart) / Math.max(1, span);
applyRange(centerTime - fraction * nextSpan, centerTime + (1 - fraction) * nextSpan);
}
function nearestIndex(values: number[], target: number): number {
let low = 0;
let high = values.length - 1;
while (low < high) {
const middle = (low + high) >> 1;
if (values[middle] < target) low = middle + 1;
else high = middle;
}
if (low > 0 && Math.abs(values[low - 1] - target) <= Math.abs(values[low] - target)) {
return low - 1;
}
return low;
}
function isDaytime(time: number): boolean {
for (let i = 0; i < sunrise.length; i++) {
if (time >= sunrise[i] && time < sunset[i]) return true;
const nextSunrise = sunrise[i + 1] ?? Infinity;
if (time >= sunset[i] && time < nextSunrise) return false;
}
return sunrise.length === 0 || time >= sunrise[0];
}
let sampleStepHours = $derived.by(() => {
const pixelsPerHour = plotWidth / Math.max(1, (viewEnd - viewStart) / HOUR);
return SAMPLE_STEPS.find((step) => step * pixelsPerHour >= MIN_ICON_GAP) ?? 24;
});
let visiblePoints = $derived.by((): TimelinePoint[] => {
const points: TimelinePoint[] = [];
const edgeInset = ICON_SIZE / 2 + 1;
const firstValidIndex = timestampsSec.findIndex((time) => {
const x = xForTime(time);
return time >= viewStart && time <= viewEnd && x >= plotInsetLeft + edgeInset;
});
if (firstValidIndex < 0) return points;
const firstTime = timestampsSec[firstValidIndex];
const canvasRight = width - edgeInset;
let previousDay = '';
for (let index = firstValidIndex; index < timestampsSec.length; index++) {
const time = timestampsSec[index];
if (time < viewStart || time > viewEnd) continue;
const x = xForTime(time);
if (x > canvasRight) break;
const hoursFromFirst = Math.round((time - firstTime) / HOUR);
if (hoursFromFirst % sampleStepHours !== 0) continue;
const date = new Date(timestamps[index]);
const day = formatZoned(date, timezone, 'yyyy-MM-dd');
points.push({
index,
time,
x,
hour: formatZoned(date, timezone, 'HH'),
date: day === previousDay ? '' : formatZoned(date, timezone, 'EEE d')
});
previousDay = day;
}
return points;
});
let daySeparators = $derived.by((): DaySeparator[] => {
const separators: DaySeparator[] = [];
let previousDay = '';
for (let index = 0; index < timestampsSec.length; index++) {
const time = timestampsSec[index];
if (time < viewStart || time > viewEnd) continue;
const day = formatZoned(new Date(timestamps[index]), timezone, 'yyyy-MM-dd');
if (previousDay && day !== previousDay) separators.push({ x: xForTime(time) });
previousDay = day;
}
return separators;
});
let sunlightSegments = $derived.by((): SunlightSegment[] => {
const segments: SunlightSegment[] = [];
for (let index = 0; index < sunrise.length; index++) {
const start = Math.max(viewStart, sunrise[index]);
const end = Math.min(viewEnd, sunset[index] ?? sunrise[index]);
if (end <= start) continue;
segments.push({ x1: xForTime(start), x2: xForTime(end) });
}
return segments;
});
let hoverX = $derived(
sharedHover !== null && sharedHover >= viewStart && sharedHover <= viewEnd
? xForTime(sharedHover)
: null
);
let hoverIndex = $derived(
sharedHover !== null && hoverX !== null && timestampsSec.length > 0
? nearestIndex(timestampsSec, sharedHover)
: -1
);
function checkNewDay(i: number, ts: number): boolean {
if (i === 0) return false;
const prevTs = timestamps[filteredIndices[i - 1]];
return (
formatZoned(new Date(ts), timezone, 'd') !== formatZoned(new Date(prevTs), timezone, 'd')
function conditionLabel(code: number): string {
if (code === 0) return m.cond_clear();
if (code === 1 || code === 2) return m.cond_fair();
if (code === 3) return m.cond_cloudy();
if (code === 45 || code === 48) return m.cond_fog();
if ([51, 53, 55, 56, 57].includes(code)) return m.cond_drizzle();
if ([71, 73, 75, 77, 85, 86].includes(code)) return m.cond_snow();
if ([95, 96, 99].includes(code)) return m.cond_thunder();
return m.cond_rain();
}
let tooltipRows = $derived.by((): TooltipRow[] => {
if (hoverIndex < 0) return [];
const rows: TooltipRow[] = [];
for (const model of displayModels) {
const code = model.variables.weather_code?.[hoverIndex];
if (!hasWeatherIcon(code)) continue;
const cloudCover = model.variables.cloud_cover?.[hoverIndex];
rows.push({
modelId: model.modelId,
label: modelLabel(model.modelId),
color: modelColor(model.modelId, modelOrder),
condition: conditionLabel(code),
cloudCover:
cloudCover !== undefined && Number.isFinite(cloudCover)
? Math.round(Math.max(0, Math.min(100, cloudCover)))
: null
});
}
return rows;
});
let tooltipVisible = $derived(hoverIndex >= 0 && tooltipRows.length > 0 && hoverX !== null);
let tooltipFlip = $derived((hoverX ?? 0) > width * 0.55);
let hoverFrame = 0;
let queuedClientX = 0;
const pointers = new Map<number, { x: number; y: number }>();
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
let selectionStartX = 0;
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
let panStart: { x: number; start: number; end: number } | null = null;
let pinchStart: { distance: number; start: number; end: number } | null = null;
onMount(() => {
const unregisterGroup = registerGroupMember(group);
const interactionElement = containerEl;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) width = entry.contentRect.width;
});
if (interactionElement) observer.observe(interactionElement);
interactionElement?.addEventListener('wheel', handleWheel, { passive: false });
const themeObserver = new MutationObserver(() => themeVersion++);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme']
});
registerExporter?.({ getExportImage });
return () => {
interactionElement?.removeEventListener('wheel', handleWheel);
observer.disconnect();
themeObserver.disconnect();
unregisterGroup();
registerExporter?.(null);
};
});
function scheduleHover(clientX: number): void {
queuedClientX = clientX;
if (hoverFrame !== 0) return;
hoverFrame = requestAnimationFrame(() => {
hoverFrame = 0;
if (!containerEl || timestampsSec.length === 0) return;
const localX = queuedClientX - containerEl.getBoundingClientRect().left;
const time =
viewStart + ((localX - plotInsetLeft) / Math.max(1, plotWidth)) * (viewEnd - viewStart);
setGroupHover(
group,
localX >= plotInsetLeft && localX <= width - plotInsetRight
? timestampsSec[nearestIndex(timestampsSec, time)]
: null
);
});
}
function handlePointerDown(event: PointerEvent): void {
if (!containerEl) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pointers.size === 2) {
containerEl.setPointerCapture(event.pointerId);
const [first, second] = [...pointers.values()];
pinchStart = {
distance: Math.max(10, Math.abs(first.x - second.x)),
start: viewStart,
end: viewEnd
};
panStart = null;
dragSelect = null;
gesture = 'pinch';
setGroupHover(group, null);
return;
}
if (event.pointerType === 'mouse') {
containerEl.setPointerCapture(event.pointerId);
selectionStartX = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
dragSelect = null;
gesture = 'select';
event.preventDefault();
} else {
touchStart = { x: event.clientX, y: event.clientY, start: viewStart, end: viewEnd };
gesture = 'none';
}
}
function handlePointerMove(event: PointerEvent): void {
if (!containerEl) return;
if (pointers.has(event.pointerId)) {
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
}
if (gesture === 'pinch' && pointers.size === 2 && pinchStart) {
const [first, second] = [...pointers.values()];
const distance = Math.max(10, Math.abs(first.x - second.x));
const scale = pinchStart.distance / distance;
const span = pinchStart.end - pinchStart.start;
const center = (pinchStart.start + pinchStart.end) / 2;
applyRange(center - (span * scale) / 2, center + (span * scale) / 2);
return;
}
if (gesture === 'select' && event.pointerType === 'mouse' && pointers.size === 1) {
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
if (dragSelect || Math.abs(x - selectionStartX) >= 3) {
dragSelect = { x0: selectionStartX, x1: x };
setGroupHover(group, null);
}
return;
}
if (event.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
const deltaX = Math.abs(event.clientX - touchStart.x);
const deltaY = Math.abs(event.clientY - touchStart.y);
if (deltaX < 6 && deltaY < 6) return;
if (deltaY > deltaX) {
gesture = 'scroll';
return;
}
gesture = zoomed ? 'pan' : 'inspect';
containerEl.setPointerCapture(event.pointerId);
if (gesture === 'pan') {
panStart = { x: event.clientX, start: touchStart.start, end: touchStart.end };
}
}
if (gesture === 'scroll') return;
if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) {
const deltaTime =
((panStart.x - event.clientX) / Math.max(1, plotWidth)) * (panStart.end - panStart.start);
applyRange(panStart.start + deltaTime, panStart.end + deltaTime);
return;
}
if (pointers.size <= 1) scheduleHover(event.clientX);
}
function handlePointerUp(event: PointerEvent): void {
if (gesture === 'select' && event.pointerType === 'mouse' && dragSelect) {
const first = timeForX(dragSelect.x0);
const second = timeForX(dragSelect.x1);
applyRange(Math.min(first, second), Math.max(first, second));
}
dragSelect = null;
pointers.delete(event.pointerId);
if (pointers.size < 2) pinchStart = null;
if (pointers.size === 0) {
panStart = null;
touchStart = null;
gesture = 'none';
}
if (event.pointerType !== 'mouse') setGroupHover(group, null);
}
function handlePointerLeave(): void {
if (pointers.size === 0) setGroupHover(group, null);
}
function handleWheel(event: WheelEvent): void {
if ((!event.ctrlKey && !event.metaKey) || !containerEl) return;
event.preventDefault();
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
zoomAt(timeForX(x), event.deltaY < 0 ? 1 / 1.3 : 1.3);
}
function handleDoubleClick(): void {
setGroupRange(group, null);
}
function handleKeydown(event: KeyboardEvent): void {
if (timestampsSec.length === 0) return;
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const current = sharedHover ?? viewStart;
const index = nearestIndex(timestampsSec, current);
const delta = event.key === 'ArrowLeft' ? -1 : 1;
const next = Math.max(0, Math.min(timestampsSec.length - 1, index + delta));
setGroupHover(group, timestampsSec[next]);
event.preventDefault();
} else if (event.key === '+' || event.key === '=') {
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1 / 1.3);
event.preventDefault();
} else if (event.key === '-' || event.key === '_') {
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1.3);
event.preventDefault();
} else if (event.key === 'Escape') {
setGroupRange(group, null);
setGroupHover(group, null);
}
}
onDestroy(() => {
if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
});
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
const key = `${name}|${color}`;
let promise = iconImageCache.get(key);
if (!promise) {
promise = fetch(`/images/weather-icons/${name}.svg`)
.then((response) => response.text())
.then(
(svg) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const colored = svg.replace(/<svg\b/, `<svg fill="${color}"`);
const url = URL.createObjectURL(new Blob([colored], { type: 'image/svg+xml' }));
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = (error) => {
URL.revokeObjectURL(url);
reject(error);
};
image.src = url;
})
);
iconImageCache.set(key, promise);
}
return promise;
}
export async function getExportImage(opts?: {
title?: string;
}): Promise<HTMLCanvasElement | null> {
if (!containerEl || width <= 0 || displayModels.length === 0) return null;
const dpr = window.devicePixelRatio || 1;
const titleHeight = opts?.title ? EXPORT_TITLE_H : 0;
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * dpr);
canvas.height = Math.round((height + titleHeight) * dpr);
const context = canvas.getContext('2d');
if (!context) return null;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
const styles = getComputedStyle(containerEl);
const color = (name: string, fallback: string): string =>
styles.getPropertyValue(name).trim() || fallback;
const background = color('--card', '#ffffff');
const foreground = color('--foreground', '#1f2937');
const muted = color('--muted-foreground', '#6b7280');
const grid = color('--border', 'rgba(0, 0, 0, 0.12)');
const sunlight = color('--chart-3', '#f6c453');
context.fillStyle = background;
context.fillRect(0, 0, width, height + titleHeight);
if (opts?.title) {
context.fillStyle = foreground;
context.font = '600 14px system-ui, -apple-system, sans-serif';
context.textBaseline = 'middle';
context.fillText(opts.title, 4, EXPORT_TITLE_H / 2 + 2);
}
const yOffset = titleHeight;
context.save();
context.beginPath();
context.rect(plotInsetLeft, yOffset, plotWidth, height);
context.clip();
const sunlightY = yOffset + HEADER_H - SUNLIGHT_STRIP_H;
context.fillStyle = muted;
context.globalAlpha = 0.12;
context.fillRect(plotInsetLeft, sunlightY, plotWidth, SUNLIGHT_STRIP_H);
context.fillStyle = sunlight;
context.globalAlpha = 0.9;
for (const segment of sunlightSegments) {
context.fillRect(segment.x1, sunlightY, segment.x2 - segment.x1, SUNLIGHT_STRIP_H);
}
context.globalAlpha = 1;
context.strokeStyle = grid;
context.lineWidth = 1;
for (const separator of daySeparators) {
context.beginPath();
context.moveTo(separator.x, yOffset);
context.lineTo(separator.x, yOffset + height);
context.stroke();
}
context.restore();
context.font = '600 10px system-ui, -apple-system, sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
for (const point of visiblePoints) {
if (point.date) {
context.fillStyle = foreground;
context.textAlign = 'left';
context.fillText(point.date, Math.max(plotInsetLeft + 3, point.x + 3), yOffset + 9);
}
context.fillStyle = muted;
context.textAlign = 'center';
context.fillText(point.hour, point.x, yOffset + 27);
}
const iconNames = new Set<string>();
for (const model of displayModels) {
for (const point of visiblePoints) {
const code = model.variables.weather_code?.[point.index];
if (!hasWeatherIcon(code)) continue;
iconNames.add(getWeatherIconName(code, isDaytime(point.time)));
}
}
const images = new Map<string, HTMLImageElement>();
await Promise.all(
[...iconNames].map(async (name) => {
try {
images.set(name, await loadColoredIcon(name, foreground));
} catch {
/* An individual missing icon should not prevent the report export. */
}
})
);
context.font = '600 11px system-ui, -apple-system, sans-serif';
context.textBaseline = 'middle';
for (let row = 0; row < displayModels.length; row++) {
const model = displayModels[row];
const rowTop = yOffset + HEADER_H + row * ROW_H;
const rowCenter = rowTop + ROW_H / 2;
context.strokeStyle = grid;
context.globalAlpha = 0.55;
context.beginPath();
context.moveTo(0, rowTop);
context.lineTo(width, rowTop);
context.stroke();
context.globalAlpha = 1;
context.fillStyle = modelColor(model.modelId, modelOrder);
context.beginPath();
context.arc(9, rowCenter, 4, 0, Math.PI * 2);
context.fill();
if (showModelNames) {
context.fillStyle = foreground;
context.textAlign = 'left';
context.fillText(
modelLabel(model.modelId),
18,
rowCenter,
Math.max(20, plotInsetLeft - 24)
);
}
context.save();
context.beginPath();
// The right chart inset is empty display space, so pictograms at the
// final chart coordinate may extend into it without being clipped.
context.rect(plotInsetLeft, rowTop, width - plotInsetLeft, ROW_H);
context.clip();
for (const point of visiblePoints) {
const code = model.variables.weather_code?.[point.index];
if (!hasWeatherIcon(code)) continue;
const cloudCover = model.variables.cloud_cover?.[point.index];
const name = getWeatherIconName(code, isDaytime(point.time));
const image = images.get(name);
if (cloudCover !== undefined && Number.isFinite(cloudCover)) {
const fraction = Math.max(0, Math.min(100, cloudCover)) / 100;
context.fillStyle = muted;
context.globalAlpha = 0.03 + 0.34 * Math.pow(fraction, 0.85);
context.beginPath();
context.roundRect(
point.x - ICON_BOX_WIDTH / 2,
rowCenter - ICON_BOX_HEIGHT / 2,
ICON_BOX_WIDTH,
ICON_BOX_HEIGHT,
4
);
context.fill();
context.globalAlpha = 1;
}
if (image) {
context.drawImage(
image,
point.x - ICON_SIZE / 2,
rowCenter - ICON_SIZE / 2,
ICON_SIZE,
ICON_SIZE
);
}
}
context.restore();
}
return canvas;
}
/**
* Determines if it's daytime for each timestamp based on sunrise/sunset data.
*/
let allDaytimeFlags = $derived(
timestamps.map((ts) => {
const tsS = ts / 1000;
for (let i = 0; i < sunrise.length; i++) {
const s = sunrise[i];
const e = sunset[i];
// Between sunrise and sunset of the same day
if (tsS >= s && tsS < e) return true;
// Between sunset of day i and sunrise of day i+1 (night)
const nextS = sunrise[i + 1] || Infinity;
if (tsS >= e && tsS < nextS) return false;
}
// Fallback: before the first sunrise
if (sunrise.length > 0 && tsS < sunrise[0]) return false;
return true;
})
);
// The visible panel is drawn through the exact same export renderer. This
// keeps icon sampling, cloud-cover boxes, labels, and plot geometry identical in
// the page and downloaded PNG.
$effect(() => {
const target = canvasEl;
const currentWidth = width;
const currentHeight = height;
void themeVersion;
void visiblePoints;
void daySeparators;
void sunlightSegments;
void displayModels;
void showModelNames;
if (!target || currentWidth <= 0 || currentHeight <= 0) return;
/**
* Filters models that actually have weather_code data available in the response.
*/
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
const version = ++screenRenderVersion;
void getExportImage().then((rendered) => {
if (!rendered || version !== screenRenderVersion || target !== canvasEl) return;
target.width = rendered.width;
target.height = rendered.height;
const context = target.getContext('2d');
if (!context) return;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, target.width, target.height);
context.drawImage(rendered, 0, 0);
});
return () => {
if (version === screenRenderVersion) screenRenderVersion++;
};
});
</script>
{#snippet weatherIcon(name: string, size: number = 24)}
<svg class="inline-block fill-foreground" width={size} height={size}>
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg>
{/snippet}
{#if displayModels.length > 0}
<div class="mt-8">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-bold">Model Comparison Timeline</h3>
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
<span class="select-none text-muted-foreground">3h</span>
<button
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
title="Toggle between 1-hour and 3-hour intervals"
>
<span
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
{hourlyInterval === 1 ? 'left-5.5' : 'left-0.75'}"
></span>
</button>
<span class="select-none text-muted-foreground">1h</span>
<section
class="mt-7 -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"
aria-labelledby="model-pictogram-title"
>
<div class="flex items-start justify-between gap-3 px-3 pt-3 pb-2 lg:px-4">
<div class="min-w-0">
<h2 id="model-pictogram-title" class="text-sm font-bold tracking-tight">
{m.compare_timeline_title()}
</h2>
<p class="text-xs text-muted-foreground">{m.compare_timeline_hint()}</p>
</div>
<button
type="button"
class="min-h-8 shrink-0 rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-semibold whitespace-nowrap text-muted-foreground hover:bg-muted hover:text-foreground"
aria-pressed={showModelNames}
onclick={onToggleModelNames}
>
{showModelNames ? m.compare_hide_model_names() : m.compare_show_model_names()}
</button>
</div>
<div class="lg:px-4">
<div
bind:this={containerEl}
class="relative w-full select-none overflow-hidden"
style:touch-action="pan-y"
role="region"
tabindex="0"
aria-label={m.compare_timeline_scroll_aria()}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
onpointerleave={handlePointerLeave}
ondblclick={handleDoubleClick}
onkeydown={handleKeydown}
>
<canvas
bind:this={canvasEl}
class="block w-full"
style:height="{height}px"
role="img"
aria-label={m.compare_timeline_caption()}
></canvas>
{#if dragSelect}
<div
class="pointer-events-none absolute top-0 z-10 border-x-2 border-primary/70 bg-primary/15"
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
style:height="{height}px"
></div>
{/if}
{#if hoverX !== null}
<div
class="pointer-events-none absolute top-0 left-0 border-l border-dashed border-muted-foreground/70 will-change-transform"
style:height="{height}px"
style:transform="translateX({hoverX}px)"
></div>
{/if}
{#if tooltipVisible && hoverX !== null && sharedHover !== null}
<div
class="pointer-events-none absolute z-20 w-max max-w-[calc(100%-1rem)] rounded-md border border-border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
style:top="{HEADER_H + 4}px"
style:left={tooltipFlip ? 'auto' : `${hoverX + 12}px`}
style:right={tooltipFlip ? '0.5rem' : 'auto'}
>
<div class="mb-1 font-semibold whitespace-nowrap">
{formatZoned(new Date(sharedHover * 1000), timezone, 'EEE d MMM HH:mm')}
</div>
{#each tooltipRows as row (row.modelId)}
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
<span class="size-2 shrink-0 rounded-full" style:background-color={row.color}
></span>
<span class="min-w-0 break-words">{row.label}:</span>
<span class="pl-2 text-right font-semibold whitespace-nowrap">
{row.condition}{#if row.cloudCover !== null}
· {m.var_cloud()} {row.cloudCover}%
{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
<div
class="overflow-x-auto rounded-lg border border-border bg-card shadow-sm"
style="scrollbar-width: thin"
>
<table class="w-full border-collapse">
<thead>
<tr class="bg-muted/30">
<th
class="sticky left-0 z-20 w-32 border-b border-r border-border bg-muted/95 p-2 text-left text-xs font-bold"
>
Model
</th>
{#each filteredIndices as idx, i (idx)}
{@const ts = timestamps[idx]}
{@const isNewDay = checkNewDay(i, ts)}
<th
class="min-w-11 border-b border-r border-border/50 p-2 text-center text-[10px] {isNewDay
? 'border-l-2 border-l-primary/30'
: ''}"
>
<div class="font-bold">{formatZoned(new Date(ts), timezone, 'HH')}</div>
<div class="text-muted-foreground">
{isNewDay ? formatZoned(new Date(ts), timezone, 'EEE d') : ''}
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each displayModels as model (model)}
<tr class="group hover:bg-muted/10">
<td
class="sticky left-0 z-10 border-b border-r border-border bg-card p-2 text-[11px] font-semibold group-hover:bg-muted/20"
>
{model.replace(/_/g, ' ')}
</td>
{#each filteredIndices as idx, i (idx)}
{@const ts = timestamps[idx]}
{@const codes = hourlyFlat[`weather_code_${model}`]}
{@const code = codes ? codes[idx] : 0}
{@const day = allDaytimeFlags[idx]}
{@const isNewDay = checkNewDay(i, ts)}
<td
class="border-b border-r border-border/30 p-1.5 text-center {isNewDay
? 'border-l-2 border-l-primary/20'
: ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}"
>
{@render weatherIcon(getWeatherIconName(code, day))}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</section>
{/if}
<style>
th,
td {
white-space: nowrap;
}
</style>
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
cardinalDirection,
isWindDirection,
modelColor,
modelMean,
sanitizeList,
validDirectionValues
} from './comparison';
import type { ModelSeriesData } from '$lib/services/weather';
const models: ModelSeriesData[] = [
{
modelId: 'model-a',
resolvedModelId: 'model-a',
variables: {
precipitation: [1, Number.NaN, 3],
precipitation_probability: [80, 90, 100]
}
},
{
modelId: 'model-b',
resolvedModelId: 'model-b',
variables: { precipitation: [3, Number.NaN, 5] }
}
];
describe('multimodel comparison helpers', () => {
it('averages only the exact scalar variable and preserves missing values', () => {
expect(modelMean(models, 'precipitation', 3)).toEqual([2, null, 4]);
});
it('validates and deduplicates shared URL selections', () => {
const allowed = new Set(['a', 'b']);
expect(sanitizeList(['a', 'unknown', 'a', 'b'], allowed)).toEqual(['a', 'b']);
expect(sanitizeList(['unknown'], allowed)).toBeNull();
});
it('treats every wind direction height as a direction series', () => {
expect(isWindDirection('wind_direction_10m')).toBe(true);
expect(isWindDirection('wind_direction_180m')).toBe(true);
expect(isWindDirection('wind_speed_10m')).toBe(false);
});
it('drops direction values outside the fixed 0360 degree domain', () => {
expect(validDirectionValues([-1, 0, 90, 360, 361, Number.NaN])).toEqual([
null,
0,
90,
360,
null,
null
]);
expect(cardinalDirection(0)).toBe('N');
expect(cardinalDirection(270)).toBe('W');
});
it('assigns deterministic model colors', () => {
expect(modelColor('ecmwf_ifs')).toBe(modelColor('ecmwf_ifs'));
expect(modelColor('ecmwf_ifs')).not.toBe(modelColor('gfs_global'));
});
});
@@ -0,0 +1,130 @@
import { findModel, models as modelOptions } from '../../options';
import type { ModelSeriesData } from '$lib/services/weather';
const MODEL_COLORS = [
'#0072b2',
'#d55e00',
'#009e73',
'#cc79a7',
'#e69f00',
'#56b4e9',
'#6f4ead',
'#8c564b',
'#e11d48',
'#65a30d',
'#0891b2',
'#c026d3',
'#2563eb',
'#ea580c',
'#059669',
'#9333ea'
] as const;
/**
* High-contrast categorical colour. Within a comparison, selection order owns
* the palette slot so adjacent series never receive accidentally similar hues.
*/
export function modelColor(modelId: string, modelOrder?: readonly string[]): string {
const selectedIndex = modelOrder?.indexOf(modelId) ?? -1;
if (selectedIndex >= 0) return MODEL_COLORS[selectedIndex % MODEL_COLORS.length]!;
const canonicalIndex = modelOptions.findIndex((model) => model.value === modelId);
if (canonicalIndex >= 0) return MODEL_COLORS[canonicalIndex % MODEL_COLORS.length]!;
let hash = 2166136261;
for (let i = 0; i < modelId.length; i++) {
hash ^= modelId.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return MODEL_COLORS[Math.abs(hash) % MODEL_COLORS.length]!;
}
export function modelLabel(modelId: string): string {
return findModel(modelId)?.label ?? modelId.replace(/_/g, ' ');
}
export function sanitizeList(
values: string[] | null,
allowed: ReadonlySet<string>
): string[] | null {
if (!values) return null;
const clean = [...new Set(values.filter((value) => allowed.has(value)))];
return clean.length > 0 ? clean : null;
}
export function isWindDirection(variable: string): boolean {
return variable.startsWith('wind_direction_');
}
export function validDirectionValues(values: number[] | undefined): (number | null)[] {
return (values ?? []).map((value) =>
Number.isFinite(value) && value >= 0 && value <= 360 ? value : null
);
}
export interface PrecipitationAgreementPoint {
wetCount: number;
availableCount: number;
median: number;
min: number;
max: number;
}
/** Per-timestamp precipitation occurrence agreement and amount spread. */
export function precipitationAgreement(
models: ModelSeriesData[],
variable: string,
timeLength: number,
wetThreshold: number
): (PrecipitationAgreementPoint | null)[] {
const points: (PrecipitationAgreementPoint | null)[] = new Array(timeLength).fill(null);
for (let index = 0; index < timeLength; index++) {
const values: number[] = [];
for (const model of models) {
const value = model.variables[variable]?.[index];
if (value === undefined || !Number.isFinite(value) || value < 0) continue;
values.push(value);
}
if (values.length === 0) continue;
values.sort((left, right) => left - right);
const middle = Math.floor(values.length / 2);
const median =
values.length % 2 === 0 ? (values[middle - 1] + values[middle]) / 2 : values[middle];
points[index] = {
wetCount: values.filter((value) => value > wetThreshold).length,
availableCount: values.length,
median,
min: values[0],
max: values[values.length - 1]
};
}
return points;
}
export function cardinalDirection(value: number): string {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
return directions[Math.round((((value % 360) + 360) % 360) / 45) % 8];
}
/** Arithmetic model mean for scalar variables; missing consensus stays missing. */
export function modelMean(
models: ModelSeriesData[],
variable: string,
timeLength: number
): (number | null)[] {
const totals = new Array<number>(timeLength).fill(0);
const counts = new Array<number>(timeLength).fill(0);
for (const model of models) {
const values = model.variables[variable] ?? [];
for (let i = 0; i < Math.min(values.length, timeLength); i++) {
const value = values[i];
if (!Number.isFinite(value)) continue;
totals[i] += value;
counts[i]++;
}
}
return totals.map((total, i) =>
counts[i] > 0 ? Math.round((total / counts[i]) * 10) / 10 : null
);
}
+20
View File
@@ -0,0 +1,20 @@
import { getContext } from 'svelte';
import type { Snippet } from 'svelte';
interface HeroContext {
setActions: (snippet: Snippet | null) => void;
}
/**
* Renders a page's own controls into the shared location row that lives in
* `weather/+layout.svelte`. The row itself stays mounted across navigation;
* only the controls swap, and they are cleared when the page unmounts.
*/
export function useHeroActions(actions: Snippet): void {
const hero = getContext<HeroContext>('weather-hero');
$effect(() => {
hero.setActions(actions);
return () => hero.setActions(null);
});
}
@@ -0,0 +1,24 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender
onMount(() => {
goto(
href('/weather/historical/[location]', {
location: buildLocationRoute(get(storedLocation))
}),
{ replaceState: true }
);
});
</script>
@@ -0,0 +1,293 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import {
storedArchiveModel,
storedChartLayout,
storedLocation,
storedUnits,
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 {
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';
import HourlyTable from '../../week/[location]/HourlyTable.svelte';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import { neededHourlyApiVars } from '../../week/[location]/variables';
import DateRangeControls from './DateRangeControls.svelte';
import HistoricalDaily from './HistoricalDaily.svelte';
import HistoricalMeteograms from './HistoricalMeteograms.svelte';
import type { FetchedDaily, FetchedHourly } from '../../week/[location]/types';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// 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);
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
});
let params = $state({ ...defaultParameters });
$effect(() => {
params.temperature_unit = $storedUnits.temperature_unit;
params.wind_speed_unit = $storedUnits.wind_speed_unit;
params.precipitation_unit = $storedUnits.precipitation_unit;
});
// Request only what the table rows + meteogram layout actually show.
let hourlyVars = $derived(
neededHourlyApiVars(
$storedVariablePrefs.table,
$storedChartLayout.flatMap((p) => p.variables)
)
);
// ─── Date range ───────────────────────────────────────────────────────────
const iso = (d: Date): string => d.toISOString().slice(0, 10);
const addDays = (d: Date, n: number): Date => {
const c = new Date(d);
c.setUTCDate(c.getUTCDate() + n);
return c;
};
const MIN_DATE = '1940-01-01'; // ERA5 archive start
// The reanalysis archive lags real time by a few days.
let maxDate = $state(iso(addDays(new Date(), -5)));
let startDate = $state(iso(addDays(new Date(), -34)));
let endDate = $state(iso(addDays(new Date(), -5)));
onMount(() => {
const today = new Date();
maxDate = iso(addDays(today, -5));
endDate = maxDate;
startDate = iso(addDays(today, -34));
mounted = true;
});
let archiveModel = $state('best_match');
onMount(() => {
archiveModel = get(storedArchiveModel);
});
function onRangeChange(s: string, e: string) {
startDate = s;
endDate = e;
}
// ─── Fetch state ────────────────────────────────────────────────────────────
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let result = $state<HistoricalForecastResult | null>(null);
let normals = $state<ClimateNormals | null>(null);
const selectedDay = new SvelteDate();
// Historical data: refetch on location / range / units / requested-vars change.
$effect(() => {
const loc = location;
const s = startDate;
const e = endDate;
const vars = hourlyVars;
const model = archiveModel;
void model;
if (!mounted || !$isSupporter || !loc || !s || !e) return;
const version = ++requestVersion;
loading = true;
loadError = null;
fetchHistoricalWeather({
latitude: loc.latitude!,
longitude: loc.longitude!,
start_date: s,
end_date: e,
hourlyVariables: vars,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
timezone: loc.timezone
})
.then((r) => {
if (version !== requestVersion) return;
result = r;
// default the hourly drill-down to the last day in range
if (r.dailyDates.length > 0) {
selectedDay.setTime(r.dailyDates[r.dailyDates.length - 1].getTime());
}
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
// Climate normals: independent of the range, so fetch once per location/units.
let normalsKey = $derived(
`${location?.latitude},${location?.longitude},${params.temperature_unit},${params.precipitation_unit}`
);
let normalsVersion = 0;
$effect(() => {
const key = normalsKey;
const loc = location;
if (!mounted || !$isSupporter || !loc) return;
const version = ++normalsVersion;
normals = null;
fetchClimateNormals({
latitude: loc.latitude!,
longitude: loc.longitude!,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
})
.then((n) => {
if (version === normalsVersion) normals = n;
})
.catch(() => {
// normals are a nice-to-have; a failure just hides the comparison
if (version === normalsVersion) normals = null;
});
// re-read key so the effect tracks it
void key;
});
// ─── Adapters so the reused week components accept historical data ──────────
let fetchedHourly = $derived<FetchedHourly | null>(
result
? {
hourly: result.hourly,
utc_offset_seconds: result.utcOffsetSeconds,
timezone: result.timezone,
timestamps: result.hourlyTimestamps,
hourlyDates: result.hourlyDates,
daylightBands: result.daylightBands
}
: null
);
let fetchedDaily = $derived<FetchedDaily | null>(
result
? {
daily: {
weather_code: result.daily.weather_code,
temperature_2m_max: result.daily.temperature_2m_max,
temperature_2m_min: result.daily.temperature_2m_min,
sunrise: result.daily.sunrise,
sunset: result.daily.sunset,
sunshine_duration: result.daily.sunshine_duration,
precipitation_sum: result.daily.precipitation_sum,
windspeed_10m_max: result.daily.windspeed_10m_max,
windgusts_10m_max: result.daily.windgusts_10m_max,
winddirection_10m_dominant: result.daily.winddirection_10m_dominant
},
timezone: result.timezone,
dailyDates: result.dailyDates
}
: null
);
function switchDay(date: Date) {
// 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="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={archiveModel}
groups={archiveModelGroups}
label={m.model_archive()}
onModelChange={(model) => {
archiveModel = model;
storedArchiveModel.set(model);
}}
/>
</div>
{/snippet}
<svelte:head>
<title>Drizz.li | {m.page_historical_subtitle()}</title>
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head>
<SupporterGate feature={m.page_historical_subtitle()}>
<DateRangeControls
start={startDate}
end={endDate}
minDate={MIN_DATE}
{maxDate}
onChange={onRangeChange}
/>
{#if loadError}
<div
class="mt-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load historical data: {loadError}
</div>
{/if}
<div class="relative mt-4">
{#if result && fetchedHourly && fetchedDaily}
<HistoricalDaily
daily={result.daily}
dailyDates={result.dailyDates}
timezone={result.timezone}
units={params}
{normals}
{selectedDay}
onSelectDay={switchDay}
/>
<div class="mt-6">
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
/>
</div>
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
{:else}
<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>
</SupporterGate>
@@ -0,0 +1,13 @@
import { resolveLocationFromRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load: PageLoad = async (event) => {
const location = await resolveLocationFromRoute({
urlLocation: event.params.location,
routePrefix: '/weather/historical/',
event
});
return { location };
};

Some files were not shown because too many files have changed in this diff Show More