Author SHA1 Message Date
Vincent van der Wal ced7fff8e5 maps components 2026-07-25 18:42:03 +02:00
502 changed files with 3661 additions and 13115 deletions
-7
View File
@@ -26,10 +26,3 @@ AGENTS.md
# Local agent/editor config # Local agent/editor config
.claude/ .claude/
# paraglide compiles the message runtime into the source tree
/src/lib/paraglide
/.inlang
# Claude Code scratch: throwaway probe scripts, never committed
/.scratch
View File
-4
View File
@@ -25,10 +25,6 @@ export default defineConfig(
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // 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 // 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', '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': [ '@typescript-eslint/no-unused-vars': [
'error', 'error',
{ {
-389
View File
@@ -1,389 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Tage-Vorhersage",
"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": "7-Tage-Vorhersage",
"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, 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",
"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 7-Tage-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_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"
}
-389
View File
@@ -1,389 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Day 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": "7-day 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, 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",
"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 7-day 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_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"
}
-389
View File
@@ -1,389 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Pronóstico de 7 días",
"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 de 7 días",
"page_compare_subtitle": "comparación de modelos",
"page_14day_subtitle": "pronóstico de conjunto a 14 días",
"page_seasonal_subtitle": "previsión estacional",
"page_historical_subtitle": "clima histórico",
"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",
"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 de 7 días.",
"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_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"
}
-389
View File
@@ -1,389 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Prévisions à 7 jours",
"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 à 7 jours",
"page_compare_subtitle": "comparaison des modèles",
"page_14day_subtitle": "prévision d'ensemble à 14 jours",
"page_seasonal_subtitle": "tendances saisonnières",
"page_historical_subtitle": "météo historique",
"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",
"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 7 jours.",
"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_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"
}
-389
View File
@@ -1,389 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Previsioni a 7 giorni",
"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 a 7 giorni",
"page_compare_subtitle": "confronto modelli",
"page_14day_subtitle": "previsione d'insieme a 14 giorni",
"page_seasonal_subtitle": "tendenze stagionali",
"page_historical_subtitle": "meteo storico",
"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",
"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 a 7 giorni.",
"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_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"
}
+322 -266
View File
@@ -7,10 +7,13 @@
"": { "": {
"name": "drizzli", "name": "drizzli",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer",
"maplibre-gl": "^5.24.0"
},
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.0", "@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@inlang/paraglide-js": "^2.23.0",
"@internationalized/date": "^3.12.2", "@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.25.0", "@lucide/svelte": "^1.25.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
@@ -47,6 +50,43 @@
"vitest-browser-svelte": "^3.0.0" "vitest-browser-svelte": "^3.0.0"
} }
}, },
"../../open-meteo/weather-map-layer": {
"name": "@openmeteo/weather-map-layer",
"version": "0.0.19",
"dependencies": {
"@mapbox/tilebelt": "^2.0.3",
"@mapbox/vector-tile": "^3.0.0",
"@openmeteo/file-reader": "^0.0.16",
"maplibre-gl": "^5.20.1",
"pbf": "^5.1.0",
"point-in-polygon-hao": "^1.2.4"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
"@types/d3-interpolate": "^3.0.4",
"@types/geojson": "^7946.0.16",
"@types/node": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"@vitest/coverage-v8": "^4.0.15",
"concurrently": "^10.0.1",
"d3-color": "^3.1.0",
"d3-interpolate": "^3.0.1",
"eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.0.0",
"jiti": "^2.6.1",
"live-server": "^1.2.2",
"npm-watch": "^0.13.0",
"prettier": "^3.6.2",
"tsx": "^4.20.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.1",
"unplugin-dts": "^1.0.0-beta.6",
"vite": "^8.0.12",
"vitest": "^4.0.4"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -496,64 +536,6 @@
"url": "https://github.com/sponsors/nzakas" "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": { "node_modules/@internationalized/date": {
"version": "3.12.2", "version": "3.12.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
@@ -614,32 +596,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@lucide/svelte": {
"version": "1.25.0", "version": "1.25.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz", "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
@@ -650,6 +606,119 @@
"svelte": "^5" "svelte": "^5"
} }
}, },
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -682,6 +751,10 @@
"node": ">=12.0" "node": ">=12.0"
} }
}, },
"node_modules/@openmeteo/weather-map-layer": {
"resolved": "../../open-meteo/weather-map-layer",
"link": true
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.139.0", "version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
@@ -792,6 +865,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -809,6 +885,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -826,6 +905,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -843,6 +925,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -860,6 +945,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -877,6 +965,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -963,23 +1054,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1222,6 +1296,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1239,6 +1316,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1256,6 +1336,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1273,6 +1356,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1465,6 +1551,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1968,13 +2060,6 @@
"node": ">= 0.4" "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": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2073,40 +2158,6 @@
"node": ">=6" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2191,21 +2242,6 @@
} }
} }
}, },
"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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2250,6 +2286,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.24.2", "version": "5.24.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
@@ -2502,20 +2544,6 @@
"url": "https://opencollective.com/eslint" "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": { "node_modules/esquery": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -2712,6 +2740,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2745,16 +2779,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2839,13 +2863,6 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2887,18 +2904,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json5": { "node_modules/json-stringify-pretty-compact": {
"version": "2.2.3", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"dev": true, "license": "MIT"
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
}, },
"engines": { "node_modules/kdbush": {
"node": ">=6" "version": "4.1.0",
} "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
}, },
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
@@ -2927,16 +2943,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3094,6 +3100,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3115,6 +3124,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3136,6 +3148,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3157,6 +3172,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3272,6 +3290,40 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
@@ -3288,6 +3340,15 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -3315,6 +3376,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -3463,6 +3530,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3662,6 +3741,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -3778,6 +3863,12 @@
} }
} }
}, },
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3788,6 +3879,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3802,6 +3899,15 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -3949,18 +4055,6 @@
"node": ">=0.10.0" "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": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -4244,6 +4338,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tinyrainbow": { "node_modules/tinyrainbow": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
@@ -4352,22 +4452,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4378,13 +4462,6 @@
"punycode": "^2.1.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4392,20 +4469,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "8.1.5", "version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
@@ -4626,13 +4689,6 @@
"vitest": "^4.0.0" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+4 -1
View File
@@ -19,7 +19,6 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.0", "@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@inlang/paraglide-js": "^2.23.0",
"@internationalized/date": "^3.12.2", "@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.25.0", "@lucide/svelte": "^1.25.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
@@ -54,5 +53,9 @@
"vite": "^8.1.5", "vite": "^8.1.5",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"vitest-browser-svelte": "^3.0.0" "vitest-browser-svelte": "^3.0.0"
},
"dependencies": {
"@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer",
"maplibre-gl": "^5.24.0"
} }
} }
-12
View File
@@ -1,12 +0,0 @@
{
"$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
@@ -1,119 +0,0 @@
/**
* 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}`
);
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="%paraglide.lang%"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
-16
View File
@@ -1,16 +0,0 @@
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
@@ -1,10 +0,0 @@
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;
+67 -514
View File
@@ -18,21 +18,17 @@
/> />
--> -->
<script module lang="ts"> <script module lang="ts">
import * as m from '$lib/paraglide/messages';
export interface ChartSeries { export interface ChartSeries {
/** Series display name (used in legend and tooltip) */ /** Series display name (used in legend and tooltip) */
name: string; name: string;
/** Render style */ /** Render style */
type: 'line' | 'bar' | 'point'; type: 'line' | 'bar';
/** Any CSS color string */ /** Any CSS color string */
color: string; color: string;
/** One value per timestamp; null values break lines */ /** One value per timestamp; null values break lines */
data: (number | null)[]; data: (number | null)[];
/** Line width in px (default 2); 0 draws only the area fill */ /** Line width in px (default 2); 0 draws only the area fill */
width?: number; width?: number;
/** Radius in px for point series (default 3). */
pointRadius?: number;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */ /** Draw a low-alpha area fill below (or above, on inverted axes) the line */
fill?: boolean; fill?: boolean;
/** Area fill coloured by the value scale (segmentColor), fading to /** Area fill coloured by the value scale (segmentColor), fading to
@@ -66,28 +62,8 @@
labelExtrema?: boolean; labelExtrema?: boolean;
/** Formatter for extrema labels (defaults to the tooltip format) */ /** Formatter for extrema labels (defaults to the tooltip format) */
labelFormat?: (value: number) => string; labelFormat?: (value: number) => string;
/** Render as a soft cloud band instead of a line (see `cloudLayer`) */ /** Render as a puffy cloud band hanging from the top instead of a line */
cloudBand?: boolean; cloudBand?: boolean;
/**
* Which slot a cloud band occupies. Omitted (total cover) hangs from the
* top of the plot; the named layers stack in their real vertical order,
* each in its own slot, so a layered chart reads like the sky itself.
*/
cloudLayer?: 'high' | 'mid' | 'low';
}
export interface ChartAgreementPoint {
wetCount: number;
availableCount: number;
median: number;
min: number;
max: number;
}
export interface ChartAgreementStrip {
points: (ChartAgreementPoint | null)[];
label: string;
format: (point: ChartAgreementPoint) => string;
} }
interface GroupState { interface GroupState {
@@ -117,17 +93,6 @@
} }
} }
/** Register a non-chart surface that participates in synchronized interactions. */
export function registerGroupMember(name: string): () => void {
acquireGroup(name);
let registered = true;
return () => {
if (!registered) return;
registered = false;
releaseGroup(name);
};
}
/** /**
* Drive the shared crosshair of a chart group from the outside (e.g. hovering * Drive the shared crosshair of a chart group from the outside (e.g. hovering
* the hourly table). `time` is epoch seconds, or null to clear. No-op if no * the hourly table). `time` is epoch seconds, or null to clear. No-op if no
@@ -135,16 +100,7 @@
*/ */
export function setGroupHover(name: string, time: number | null): void { export function setGroupHover(name: string, time: number | null): void {
const state = groups[name]; const state = groups[name];
if (state && state.hover !== time) state.hover = time; if (state) state.hover = time;
}
/** Set the synchronized visible time range for a registered chart group. */
export function setGroupRange(
name: string,
range: { start: number; end: number } | null
): void {
const state = groups[name];
if (state) state.range = range;
} }
/** Current shared zoom range of a group (null = full range), reactive. */ /** Current shared zoom range of a group (null = full range), reactive. */
@@ -166,7 +122,6 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { formatZoned, getZonedHour } from '$lib/utils/date'; import { formatZoned, getZonedHour } from '$lib/utils/date';
import { useNow } from '$lib/utils/now.svelte';
import { CHART_COLORS } from './data'; import { CHART_COLORS } from './data';
@@ -187,8 +142,6 @@
windArrows?: { t: number; deg: number }[]; windArrows?: { t: number; deg: number }[];
/** Highlighted time range (epoch seconds), e.g. the selected day */ /** Highlighted time range (epoch seconds), e.g. the selected day */
highlight?: { start: number; end: number }; highlight?: { start: number; end: number };
/** Compact per-timestamp model-agreement band below the plot. */
agreementStrip?: ChartAgreementStrip;
/** Unit label for the left y axis (also used in tooltip values) */ /** Unit label for the left y axis (also used in tooltip values) */
unit?: string; unit?: string;
/** Unit label for the right y axis; when set, right-axis labels are drawn */ /** Unit label for the right y axis; when set, right-axis labels are drawn */
@@ -199,18 +152,8 @@
group?: string; group?: string;
/** Fixed left-axis minimum (otherwise derived from data, including 0) */ /** Fixed left-axis minimum (otherwise derived from data, including 0) */
yMin?: number; yMin?: number;
/** Minimum breathing room (axis units) above the left-axis data range. */
yPadTop?: number;
/** Minimum breathing room (axis units) below the left-axis data range. */
yPadBottom?: number;
/** Fixed left-axis maximum */ /** Fixed left-axis maximum */
yMax?: number; yMax?: number;
/** Explicit left-axis tick values (for categorical/fixed domains). */
yTicks?: number[];
/** Custom left-axis tick formatter. */
yTickFormat?: (value: number) => string;
/** Use abbreviated y-axis labels and omit verbose gutter labels. */
compactYAxis?: boolean;
/** Force the derived left axis to include zero (default true) */ /** Force the derived left axis to include zero (default true) */
zeroBaseLeft?: boolean; zeroBaseLeft?: boolean;
/** Fixed right-axis minimum (default 0) */ /** Fixed right-axis minimum (default 0) */
@@ -225,10 +168,6 @@
/** Reserve this many top icon-rows even if this chart has fewer, so a row /** Reserve this many top icon-rows even if this chart has fewer, so a row
* of stacked charts share the same plot rectangle */ * of stacked charts share the same plot rectangle */
reserveTopRows?: number; reserveTopRows?: number;
/** Override the plot's left inset so heterogeneous synchronized panels align. */
plotInsetLeft?: number;
/** Override the plot's right inset so heterogeneous synchronized panels align. */
plotInsetRight?: number;
/** Chart title drawn top-left on the canvas */ /** Chart title drawn top-left on the canvas */
title?: string; title?: string;
/** Smaller subtitle drawn under the title */ /** Smaller subtitle drawn under the title */
@@ -239,8 +178,6 @@
showNow?: boolean; showNow?: boolean;
/** Draw the Open-Meteo.com credit bottom-right */ /** Draw the Open-Meteo.com credit bottom-right */
showCredit?: boolean; showCredit?: boolean;
/** Accessible description for the interactive canvas. */
ariaLabel?: string;
/** Optional CSS class for the outer container */ /** Optional CSS class for the outer container */
class?: string; class?: string;
} }
@@ -253,44 +190,29 @@
pictograms = [], pictograms = [],
windArrows = [], windArrows = [],
highlight, highlight,
agreementStrip,
unit = '', unit = '',
unitRight, unitRight,
height = 300, height = 300,
group, group,
yMin, yMin,
yMax, yMax,
yTicks,
yTickFormat,
compactYAxis = false,
yPadTop,
yPadBottom,
zeroBaseLeft = true, zeroBaseLeft = true,
yMinRight, yMinRight,
yMaxRight, yMaxRight,
invertRight = false, invertRight = false,
reserveRightAxis = false, reserveRightAxis = false,
reserveTopRows = 0, reserveTopRows = 0,
plotInsetLeft,
plotInsetRight,
title, title,
subtitle, subtitle,
showLegend = false, showLegend = false,
showNow = true, showNow = true,
showCredit = false, showCredit = false,
ariaLabel,
class: className = '' class: className = ''
}: Props = $props(); }: Props = $props();
// Minute-resolution clock shared with the rest of the app; read in draw() so
// the current-time marker stays put as time passes.
const clock = useNow();
// ─── Constants ────────────────────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────────────────────
const PAD_BOTTOM = 34; const PAD_BOTTOM = 34;
const AGREEMENT_STRIP_HEIGHT = 10;
const AGREEMENT_STRIP_GAP = 3;
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600; const HOUR = 3600;
// Top icon rows (weather pictograms / wind arrows) // Top icon rows (weather pictograms / wind arrows)
@@ -301,13 +223,8 @@
// edge inset used for BOTH rows so pictograms and arrows clamp to the same // edge inset used for BOTH rows so pictograms and arrows clamp to the same
// centre and stay aligned with each other // centre and stay aligned with each other
const ICON_EDGE = ARROW_PX / 2; const ICON_EDGE = ARROW_PX / 2;
// Total cloud cover: 100% reaches this far down from the top of the plot. // Puffy cloud band: 100% cover hangs 40px from the top of the plot
const CLOUD_BAND_MAX = 48; const CLOUD_BAND_MAX = 40;
// Layered cover (high / mid / low): each layer owns a slot of this height and
// grows symmetrically out of the centre line of that slot.
const CLOUD_LAYER_H = 42;
const CLOUD_LAYER_GAP = 6;
const CLOUD_LAYER_ORDER = ['high', 'mid', 'low'] as const;
/** Return a colour string with the given alpha (handles rgb/rgba/#hex). */ /** Return a colour string with the given alpha (handles rgb/rgba/#hex). */
function withAlpha(color: string, alpha: number): string { function withAlpha(color: string, alpha: number): string {
@@ -333,97 +250,6 @@
return color; return color;
} }
function agreementMarkerColor(point: ChartAgreementPoint): string {
if (point.wetCount === 0) return '#6b7280';
const wetShare = point.wetCount / Math.max(1, point.availableCount);
return wetShare <= 0.5 ? '#d97706' : '#2563eb';
}
function agreementFillColor(point: ChartAgreementPoint, mutedColor: string): string {
if (point.wetCount === 0) return withAlpha(mutedColor, 0.3);
const wetShare = point.wetCount / Math.max(1, point.availableCount);
const agreement = Math.abs(wetShare - 0.5) * 2;
if (wetShare <= 0.5) return withAlpha('#d97706', 0.35 + (1 - agreement) * 0.35);
return withAlpha('#2563eb', 0.3 + agreement * 0.6);
}
/**
* Light [1 2 1] smoothing pass. Cloud cover is noisy hour to hour; smoothing
* the values before interpolating gives the band a slow, rolling silhouette
* instead of one that tracks every single sample.
*/
function smoothCover(values: number[], passes = 2): number[] {
let out = values;
for (let p = 0; p < passes; p++) {
const next = new Array<number>(out.length);
for (let i = 0; i < out.length; i++) {
const prev = out[i - 1] ?? out[i];
const nxt = out[i + 1] ?? out[i];
next[i] = (prev + 2 * out[i] + nxt) / 4;
}
out = next;
}
return out;
}
/** Vertical offset of a cloud series' slot from the top of the plot. */
function cloudSlotOffset(s: ChartSeries): number {
if (!s.cloudLayer) return 0;
return CLOUD_LAYER_ORDER.indexOf(s.cloudLayer) * (CLOUD_LAYER_H + CLOUD_LAYER_GAP);
}
/**
* Traces a monotone cubic (Fritsch-Carlson) curve through the points. Unlike a
* plain Catmull-Rom spline it never overshoots the data, so a cloud band can't
* bulge past 0% or 100% between two samples.
*/
function traceMonotone(
ctx: CanvasRenderingContext2D,
pts: Array<[number, number]>,
continuePath = false
): void {
const n = pts.length;
if (!continuePath) ctx.moveTo(pts[0][0], pts[0][1]);
if (n === 2) {
ctx.lineTo(pts[1][0], pts[1][1]);
return;
}
// secant slopes, then tangents averaged from the neighbouring secants
const slope: number[] = [];
for (let i = 0; i < n - 1; i++) {
const dx = pts[i + 1][0] - pts[i][0];
slope.push(dx === 0 ? 0 : (pts[i + 1][1] - pts[i][1]) / dx);
}
const m: number[] = [slope[0]];
for (let i = 1; i < n - 1; i++) m.push((slope[i - 1] + slope[i]) / 2);
m.push(slope[n - 2]);
// clamp the tangents back onto the monotone circle of radius 3
for (let i = 0; i < n - 1; i++) {
if (slope[i] === 0) {
m[i] = 0;
m[i + 1] = 0;
continue;
}
const a = m[i] / slope[i];
const b = m[i + 1] / slope[i];
const h = a * a + b * b;
if (h > 9) {
const t = 3 / Math.sqrt(h);
m[i] = t * a * slope[i];
m[i + 1] = t * b * slope[i];
}
}
for (let i = 0; i < n - 1; i++) {
const [x0, y0] = pts[i];
const [x1, y1] = pts[i + 1];
const dx = (x1 - x0) / 3;
ctx.bezierCurveTo(x0 + dx, y0 + m[i] * dx, x1 - dx, y1 - m[i + 1] * dx, x1, y1);
}
}
// ─── State ────────────────────────────────────────────────────────────────── // ─── State ──────────────────────────────────────────────────────────────────
let containerEl: HTMLDivElement | undefined = $state(); let containerEl: HTMLDivElement | undefined = $state();
@@ -441,10 +267,6 @@
const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null; const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null;
let localRange = $state<{ start: number; end: number } | null>(null); let localRange = $state<{ start: number; end: number } | null>(null);
let localHover = $state<number | null>(null); let localHover = $state<number | null>(null);
// Pointer hover is visual only. Live-region announcements are enabled by
// keyboard navigation so mouse movement does not continuously invalidate the
// accessibility tree (and only the keyboard-focused chart announces changes).
let announceHover = $state(false);
onDestroy(() => { onDestroy(() => {
if (groupName) releaseGroup(groupName); if (groupName) releaseGroup(groupName);
@@ -472,39 +294,21 @@
// Minimal gutters on narrow screens so the plot uses nearly the full width // Minimal gutters on narrow screens so the plot uses nearly the full width
// (just enough to keep the axis tick labels legible). // (just enough to keep the axis tick labels legible).
// Compact gutters (small axis padding, short icon rows) apply to phones AND let isNarrow = $derived(width > 0 && width < 520);
// tablets: below `lg` the page is edge-to-edge, so wide desktop-style axis let padLeft = $derived(isNarrow ? 26 : 60);
// margins would waste most of the width. Desktop keeps its roomier metrics.
let belowDesktop = $state(false);
onMount(() => {
const mq = window.matchMedia('(max-width: 1023px)');
const apply = () => (belowDesktop = mq.matches);
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
let isNarrow = $derived(belowDesktop || (width > 0 && width < 520));
let padLeft = $derived(plotInsetLeft ?? (isNarrow ? (yTicks?.length ? 52 : 26) : 60));
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis) // Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
// has a right axis, so a stacked row of charts share the same plot width. On // has a right axis, so a stacked row of charts share the same plot width. On
// narrow (mobile) screens we don't reserve it — the graph uses the full width // narrow (mobile) screens we don't reserve it — the graph uses the full width
// and the right-axis labels are drawn overlaid on top instead (see below). // and the right-axis labels are drawn overlaid on top instead (see below).
let padRight = $derived( let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
plotInsetRight ?? (isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20)
);
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps // Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
// a stacked row of charts the same height even if some have fewer icon rows // a stacked row of charts the same height even if some have fewer icon rows.
// but only on wide screens: on mobile that uniform band wastes precious
// vertical space, so each chart reserves just its own rows there.
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0)); let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows)); let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
// tighter top/bottom gutters on mobile so charts don't waste vertical space // tighter top/bottom gutters on mobile so charts don't waste vertical space
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H); const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 22 : 28) + iconRows * iconRowH); let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
let agreementStripOffset = $derived( let padBottom = $derived(isNarrow ? 24 : PAD_BOTTOM);
agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0
);
let padBottom = $derived((isNarrow ? 24 : PAD_BOTTOM) + agreementStripOffset);
let plotW = $derived(Math.max(1, width - padLeft - padRight)); let plotW = $derived(Math.max(1, width - padLeft - padRight));
let plotH = $derived(Math.max(1, height - padTop - padBottom)); let plotH = $derived(Math.max(1, height - padTop - padBottom));
@@ -549,38 +353,16 @@
return [lo, hi]; return [lo, hi];
} }
function buildScale( function buildScale(lo: number, hi: number, loFixed: boolean, hiFixed: boolean): Scale {
lo: number,
hi: number,
loFixed: boolean,
hiFixed: boolean,
halfStepBounds = false
): Scale {
const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true); const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true);
// Padded axes (e.g. temperature) may end on HALF steps — 5° when ticks const min = loFixed ? lo : Math.floor(lo / step) * step;
// are every 10° — so the requested margin isn't inflated to a whole step. const max = hiFixed ? hi : Math.ceil(hi / step) * step;
// Tick drawing starts at the first full-step multiple, so a half-step
// bound gets no label or gridline of its own.
const snap = halfStepBounds ? step / 2 : step;
const min = loFixed ? lo : Math.floor(lo / snap) * snap;
const max = hiFixed ? hi : Math.ceil(hi / snap) * snap;
return { min, max: max > min ? max : min + step, step }; return { min, max: max > min ? max : min + step, step };
} }
/** First tick at or above the scale minimum (bounds may sit on half steps). */
function firstTick(scale: Scale): number {
return Math.ceil((scale.min - 1e-9) / scale.step) * scale.step;
}
let leftScale = $derived.by((): Scale => { let leftScale = $derived.by((): Scale => {
let [dLo, dHi] = dataExtent('left', zeroBaseLeft); const [dLo, dHi] = dataExtent('left', zeroBaseLeft);
// requested breathing room around the data (e.g. temperature): at least return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
// the given units, growing with wide ranges so it stays proportionate
const span = dHi - dLo;
const padded = yPadTop != null || yPadBottom != null;
if (yMax === undefined && yPadTop) dHi += Math.max(yPadTop, span * 0.08);
if (yMin === undefined && yPadBottom) dLo -= Math.max(yPadBottom, span * 0.12);
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined, padded);
}); });
let rightScale = $derived.by((): Scale => { let rightScale = $derived.by((): Scale => {
@@ -618,11 +400,8 @@
} }
function setHover(time: number | null): void { function setHover(time: number | null): void {
if (groupState) { if (groupState) groupState.hover = time;
if (groupState.hover !== time) groupState.hover = time; else localHover = time;
} else if (localHover !== time) {
localHover = time;
}
} }
function applyRange(start: number, end: number): void { function applyRange(start: number, end: number): void {
@@ -654,7 +433,7 @@
// The canvas alone omits the DOM overlays (weather/wind icons), the panel // The canvas alone omits the DOM overlays (weather/wind icons), the panel
// title (rendered by the parent), and the legend (an HTML row). This builds a // title (rendered by the parent), and the legend (an HTML row). This builds a
// standalone canvas that includes all of them so downloads look like the page. // standalone canvas that includes all of them so downloads look like the page.
const iconImageCache = new SvelteMap<string, Promise<HTMLImageElement>>(); const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> { function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
const key = `${name}|${color}`; const key = `${name}|${color}`;
let p = iconImageCache.get(key); let p = iconImageCache.get(key);
@@ -790,11 +569,7 @@
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = 'alphabetic';
ctx.fillStyle = muted; ctx.fillStyle = muted;
ctx.globalAlpha = 0.8; ctx.globalAlpha = 0.8;
ctx.fillText( ctx.fillText('Weather data by Open-Meteo · visualisation by Drizz.li', width - 6, totalH - 5);
`${m.footer_data_by()} Open-Meteo · ${m.chart_credit_viz()} Drizz.li`,
width - 6,
totalH - 5
);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -847,23 +622,12 @@
: `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`; : `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`;
rows.push({ name: s.shortName ?? s.name, color: s.color, value }); rows.push({ name: s.shortName ?? s.name, color: s.color, value });
} }
const agreementPoint = agreementStrip?.points[hoverIdx];
if (agreementStrip && agreementPoint && agreementPoint.availableCount > 0) {
rows.push({
name: agreementStrip.label,
color: agreementMarkerColor(agreementPoint),
value: agreementStrip.format(agreementPoint)
});
}
return rows; return rows;
}); });
let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0); let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0);
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0); let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
let tooltipFlip = $derived(tooltipX > width * 0.55); let tooltipFlip = $derived(tooltipX > width * 0.55);
let crosshairVisible = $derived(
hoverIdx >= 0 && timestamps[hoverIdx] >= viewStart && timestamps[hoverIdx] <= viewEnd && width > 0
);
// ─── Pictograms (DOM overlay across the top) ───────────────────────────────── // ─── Pictograms (DOM overlay across the top) ─────────────────────────────────
@@ -1075,13 +839,7 @@
ctx.font = font; ctx.font = font;
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const leftTicks = for (let v = leftScale.min; v <= leftScale.max + leftScale.step / 2; v += leftScale.step) {
yTicks ??
Array.from(
{ length: Math.floor((leftScale.max - firstTick(leftScale)) / leftScale.step) + 1 },
(_, i) => firstTick(leftScale) + i * leftScale.step
);
for (const v of leftTicks) {
const y = yPix(v, 'left'); const y = yPix(v, 'left');
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
@@ -1090,13 +848,7 @@
ctx.lineTo(plotRight, y); ctx.lineTo(plotRight, y);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
ctx.fillText( ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
compactYAxis
? v.toFixed(tickDecimals(leftScale.step))
: (yTickFormat?.(v) ?? v.toFixed(tickDecimals(leftScale.step))),
padLeft - 8,
y
);
} }
// Right axis labels in the gutter (desktop only). On mobile there is no // Right axis labels in the gutter (desktop only). On mobile there is no
@@ -1106,7 +858,7 @@
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
for ( for (
let v = firstTick(rightScale); let v = rightScale.min;
v <= rightScale.max + rightScale.step / 2; v <= rightScale.max + rightScale.step / 2;
v += rightScale.step v += rightScale.step
) { ) {
@@ -1137,7 +889,7 @@
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
} }
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(tick.label, x, plotBottom + agreementStripOffset + 7); ctx.fillText(tick.label, x, plotBottom + 7);
} }
// Series (clipped to the plot area) // Series (clipped to the plot area)
@@ -1148,104 +900,23 @@
const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR; const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR;
// Cloud bands: cover drives how far the band reaches into its slot, traced // Puffy cloud band: overlapping circles hang from the top of the plot,
// as a monotone-interpolated curve so the silhouette flows instead of // each reaching down by (cover/100) × CLOUD_BAND_MAX. Neighbouring puffs
// stepping hour to hour. Both gradients run from near-transparent at the // merge into a soft, rounded silhouette.
// anchor to solid at full cover, so a 10% sky barely registers while an
// overcast one is unmistakable.
//
// * total cover hangs from the top of the plot
// * high / mid / low each grow symmetrically out of their slot's centre
// line, stacked in the order the layers actually sit in the sky
for (const s of cloudBandSeries) { for (const s of cloudBandSeries) {
const slotTop = padTop + cloudSlotOffset(s);
const layered = s.cloudLayer != null;
const slotH = layered ? CLOUD_LAYER_H : CLOUD_BAND_MAX;
const centre = slotTop + slotH / 2;
// Samples just outside the viewport are kept so the curve enters and
// leaves the plot at the right slope.
const xs: number[] = [];
const covers: number[] = [];
for (let i = 0; i < timestamps.length; i++) {
const t = timestamps[i];
if (t < viewStart - interval * 2 || t > viewEnd + interval * 2) continue;
const v = s.data[i];
xs.push(xPix(t));
covers.push(
v === null || v === undefined || !isFinite(v) ? 0 : Math.min(100, Math.max(0, v))
);
}
if (xs.length < 2) continue;
const smoothed = smoothCover(covers);
const top: Array<[number, number]> = [];
const bottom: Array<[number, number]> = [];
for (let i = 0; i < xs.length; i++) {
const frac = smoothed[i] / 100;
if (layered) {
const half = frac * (slotH / 2);
top.push([xs[i], centre - half]);
bottom.push([xs[i], centre + half]);
} else {
top.push([xs[i], slotTop]);
bottom.push([xs[i], slotTop + frac * slotH]);
}
}
// The gradient runs ALONG the series rather than top-to-bottom: every
// sample contributes a stop at its own x, so the band's density tracks
// the cover itself - a clear spell dissolves, an overcast one goes
// solid - and the fade always lines up with the silhouette above it.
const first = xs[0];
const span = xs[xs.length - 1] - first || 1;
const base = layered ? 0.08 : 0.05;
const peak = layered ? 0.92 : 0.68;
// Total cover ramps up late: a broken sky stays nearly clear on the
// plot and the density only really builds as it closes over.
const curve = layered ? 1 : 2.2;
const fill = ctx.createLinearGradient(first, 0, xs[xs.length - 1], 0);
// The edge follows the same values but starts later still: below a
// fifth of the sky there is no silhouette to draw at all.
const EDGE_FLOOR = 20;
const edge = layered ? null : ctx.createLinearGradient(first, 0, xs[xs.length - 1], 0);
for (let i = 0; i < xs.length; i++) {
const pos = Math.max(0, Math.min(1, (xs[i] - first) / span));
const cover = smoothed[i];
fill.addColorStop(
pos,
withAlpha(s.color, base + (peak - base) * Math.pow(cover / 100, curve))
);
if (edge) {
const above = Math.max(0, (cover - EDGE_FLOOR) / (100 - EDGE_FLOOR));
edge.addColorStop(pos, withAlpha(s.color, 0.85 * Math.pow(above, 1.6)));
}
}
ctx.save(); ctx.save();
ctx.fillStyle = s.color;
ctx.globalAlpha = 0.5;
for (let i = 0; i < timestamps.length; i++) {
const v = s.data[i];
if (v === null || v === undefined || !isFinite(v) || v <= 0) continue;
const t = timestamps[i];
if (t < viewStart - interval || t > viewEnd + interval) continue;
const r = (Math.min(100, v) / 100) * CLOUD_BAND_MAX;
if (r < 1) continue;
ctx.beginPath(); ctx.beginPath();
traceMonotone(ctx, bottom); ctx.arc(xPix(t), padTop, r, 0, Math.PI * 2);
if (layered) {
// back along the mirrored upper edge
ctx.lineTo(top[top.length - 1][0], top[top.length - 1][1]);
traceMonotone(ctx, [...top].reverse(), true);
} else {
ctx.lineTo(bottom[bottom.length - 1][0], slotTop);
ctx.lineTo(bottom[0][0], slotTop);
}
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill(); ctx.fill();
// The layers are gradient only - an outline would fight the soft mass
// they are meant to look like.
if (edge) {
ctx.beginPath();
traceMonotone(ctx, bottom);
ctx.strokeStyle = edge;
ctx.lineWidth = 1.25;
ctx.lineJoin = 'round';
ctx.stroke();
} }
ctx.restore(); ctx.restore();
} }
@@ -1274,24 +945,6 @@
continue; continue;
} }
if (s.type === 'point') {
const radius = s.pointRadius ?? 3;
ctx.fillStyle = s.color;
ctx.strokeStyle = outlineColor;
ctx.lineWidth = 1;
for (let i = 0; i < timestamps.length; i++) {
const v = s.data[i];
if (v === null || v === undefined || !isFinite(v)) continue;
const t = timestamps[i];
if (t < viewStart || t > viewEnd) continue;
ctx.beginPath();
ctx.arc(xPix(t), yPix(v, axis), radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
continue;
}
// Line series: draw fill and stroke per contiguous non-null run // Line series: draw fill and stroke per contiguous non-null run
// (points outside the view are handled by the clip rect). Each point // (points outside the view are handled by the clip rect). Each point
// is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set. // is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set.
@@ -1500,7 +1153,7 @@
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.lineJoin = 'round'; ctx.lineJoin = 'round';
for ( for (
let v = firstTick(rightScale); let v = rightScale.min;
v <= rightScale.max + rightScale.step / 2; v <= rightScale.max + rightScale.step / 2;
v += rightScale.step v += rightScale.step
) { ) {
@@ -1513,10 +1166,9 @@
} }
} }
// Current time marker. Reading the shared clock (rather than Date.now()) // Current time marker
// makes the enclosing draw effect re-run every minute, so the line moves.
if (showNow) { if (showNow) {
const now = clock.current.getTime() / 1000; const now = Date.now() / 1000;
if (now >= viewStart && now <= viewEnd) { if (now >= viewStart && now <= viewEnd) {
const x = xPix(now); const x = xPix(now);
ctx.strokeStyle = CHART_COLORS.currentTimeLine; ctx.strokeStyle = CHART_COLORS.currentTimeLine;
@@ -1528,44 +1180,24 @@
} }
} }
ctx.restore(); // Crosshair (snapped to the nearest timestamp)
if (hoverIdx >= 0) {
if (agreementStrip) { const t = timestamps[hoverIdx];
const stripTop = plotBottom + AGREEMENT_STRIP_GAP; if (t >= viewStart && t <= viewEnd) {
ctx.save(); const x = xPix(t);
ctx.beginPath(); ctx.strokeStyle = textColor;
ctx.rect(padLeft, stripTop, plotW, AGREEMENT_STRIP_HEIGHT);
ctx.clip();
for (let index = 0; index < timestamps.length; index++) {
const point = agreementStrip.points[index];
if (!point || point.availableCount === 0) continue;
const time = timestamps[index];
if (time < viewStart - interval || time > viewEnd + interval) continue;
const left = Math.max(padLeft, xPix(time - interval / 2));
const right = Math.min(plotRight, xPix(time + interval / 2));
if (right <= left) continue;
ctx.fillStyle = agreementFillColor(point, textColor);
ctx.fillRect(left, stripTop, Math.max(1, right - left + 0.5), AGREEMENT_STRIP_HEIGHT);
}
ctx.restore();
ctx.strokeStyle = withAlpha(gridColor, 0.8);
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1); ctx.setLineDash([4, 4]);
if (!compactYAxis) { ctx.beginPath();
ctx.fillStyle = textColor; ctx.moveTo(x, padTop);
ctx.font = '600 9px system-ui, sans-serif'; ctx.lineTo(x, plotBottom);
ctx.textAlign = 'right'; ctx.stroke();
ctx.textBaseline = 'middle'; ctx.setLineDash([]);
ctx.fillText(
agreementStrip.label,
padLeft - 6,
stripTop + AGREEMENT_STRIP_HEIGHT / 2,
Math.max(24, padLeft - 10)
);
} }
} }
ctx.restore();
// Axis unit labels // Axis unit labels
ctx.font = font; ctx.font = font;
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = 'alphabetic';
@@ -1657,47 +1289,13 @@
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none'; let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
let touchStart: { x: number; y: number; start: number; end: number } | null = null; let touchStart: { x: number; y: number; start: number; end: number } | null = null;
let selStartX = 0; // drag-to-zoom anchor (canvas-local px) let selStartX = 0; // drag-to-zoom anchor (canvas-local px)
let hoverFrame = 0;
let queuedClientX = 0;
let queuedHoverAction: 'none' | 'position' | 'clear' = 'none';
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left; const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x)); const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x));
const scheduleHoverFrame = (): void => { const updateHover = (e: PointerEvent): void => {
if (hoverFrame !== 0) return; const t = pixToTime(localX(e));
hoverFrame = requestAnimationFrame(() => { setHover(t >= viewStart && t <= viewEnd ? t : null);
hoverFrame = 0;
const action = queuedHoverAction;
queuedHoverAction = 'none';
if (action === 'clear') {
setHover(null);
return;
}
if (action !== 'position') return;
// Geometry reads and timestamp lookup happen once per animation frame,
// even when a high-polling pointer emits many events between frames.
const t = pixToTime(queuedClientX - el.getBoundingClientRect().left);
const time =
t >= viewStart && t <= viewEnd && timestamps.length > 0
? timestamps[nearestIndex(timestamps, t)]
: null;
setHover(time);
});
};
const scheduleHoverPosition = (clientX: number): void => {
if (announceHover) announceHover = false;
queuedClientX = clientX;
queuedHoverAction = 'position';
scheduleHoverFrame();
};
const scheduleHoverClear = (): void => {
if (announceHover) announceHover = false;
queuedHoverAction = 'clear';
scheduleHoverFrame();
}; };
const onPointerDown = (e: PointerEvent): void => { const onPointerDown = (e: PointerEvent): void => {
@@ -1709,7 +1307,7 @@
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd }; pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
panStart = null; panStart = null;
gesture = 'pinch'; gesture = 'pinch';
scheduleHoverClear(); setHover(null);
return; return;
} }
@@ -1749,7 +1347,7 @@
const x = clampPlotX(localX(e)); const x = clampPlotX(localX(e));
if (dragSelect || Math.abs(x - selStartX) >= 3) { if (dragSelect || Math.abs(x - selStartX) >= 3) {
dragSelect = { x0: selStartX, x1: x }; dragSelect = { x0: selStartX, x1: x };
scheduleHoverClear(); setHover(null);
} }
return; return;
} }
@@ -1779,7 +1377,7 @@
return; return;
} }
if (pointers.size <= 1) scheduleHoverPosition(e.clientX); if (pointers.size <= 1) updateHover(e);
}; };
const onPointerUp = (e: PointerEvent): void => { const onPointerUp = (e: PointerEvent): void => {
@@ -1798,11 +1396,11 @@
touchStart = null; touchStart = null;
gesture = 'none'; gesture = 'none';
} }
if (e.pointerType !== 'mouse') scheduleHoverClear(); if (e.pointerType !== 'mouse') setHover(null);
}; };
const onPointerLeave = (): void => { const onPointerLeave = (): void => {
if (pointers.size === 0) scheduleHoverClear(); if (pointers.size === 0) setHover(null);
}; };
const onWheel = (e: WheelEvent): void => { const onWheel = (e: WheelEvent): void => {
@@ -1836,7 +1434,6 @@
el.removeEventListener('pointerleave', onPointerLeave); el.removeEventListener('pointerleave', onPointerLeave);
el.removeEventListener('wheel', onWheel); el.removeEventListener('wheel', onWheel);
el.removeEventListener('dblclick', onDblClick); el.removeEventListener('dblclick', onDblClick);
if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
}; };
}); });
@@ -1844,29 +1441,6 @@
if (legendHidden.has(name)) legendHidden.delete(name); if (legendHidden.has(name)) legendHidden.delete(name);
else legendHidden.add(name); else legendHidden.add(name);
} }
function handleKeydown(event: KeyboardEvent): void {
if (timestamps.length === 0) return;
const current = hoverTime ?? viewStart;
const index = nearestIndex(timestamps, current);
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const delta = event.key === 'ArrowLeft' ? -1 : 1;
const next = Math.max(0, Math.min(timestamps.length - 1, index + delta));
announceHover = true;
setHover(timestamps[next]);
event.preventDefault();
} else if (event.key === '+' || event.key === '=') {
zoomAt(current, 1 / 1.3);
event.preventDefault();
} else if (event.key === '-' || event.key === '_') {
zoomAt(current, 1.3);
event.preventDefault();
} else if (event.key === 'Escape') {
announceHover = false;
setViewRange(null);
setHover(null);
}
}
</script> </script>
<div bind:this={containerEl} class="relative w-full select-none {className}"> <div bind:this={containerEl} class="relative w-full select-none {className}">
@@ -1876,16 +1450,7 @@
class="block w-full" class="block w-full"
style:height="{height}px" style:height="{height}px"
style:touch-action="pan-y" style:touch-action="pan-y"
tabindex="0"
aria-label={ariaLabel}
onkeydown={handleKeydown}
></canvas> ></canvas>
<p class="sr-only" aria-live="polite">
{#if announceHover && tooltipVisible}
{formatZoned(new Date(timestamps[hoverIdx] * 1000), timezone, 'EEE d MMM HH:mm')}:
{tooltipRows.map((row) => `${row.name} ${row.value}`).join(', ')}
{/if}
</p>
<!-- Drag-to-zoom selection rectangle --> <!-- Drag-to-zoom selection rectangle -->
{#if dragSelect} {#if dragSelect}
@@ -1894,7 +1459,7 @@
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px" style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
style:top="{padTop}px" style:top="{padTop}px"
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px" style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
style:height="{plotH + agreementStripOffset}px" style:height="{plotH}px"
></div> ></div>
{/if} {/if}
@@ -1947,17 +1512,6 @@
</div> </div>
{/if} {/if}
<!-- Hover is intentionally a DOM overlay: moving the crosshair must not
repaint the expensive base canvas or every synchronized chart. -->
{#if crosshairVisible}
<div
class="pointer-events-none absolute left-0 z-[15] border-l border-dashed border-muted-foreground/70 will-change-transform"
style:transform="translateX({tooltipX}px)"
style:top="{padTop}px"
style:height="{plotH + agreementStripOffset}px"
></div>
{/if}
{#if tooltipVisible} {#if tooltipVisible}
<div <div
class="pointer-events-none absolute z-20 rounded-md border border-border bg-popover px-3 py-2 text-xs whitespace-nowrap text-popover-foreground shadow-md" class="pointer-events-none absolute z-20 rounded-md border border-border bg-popover px-3 py-2 text-xs whitespace-nowrap text-popover-foreground shadow-md"
@@ -1996,7 +1550,7 @@
? 'opacity-40' ? 'opacity-40'
: ''}" : ''}"
onclick={() => toggleSeries(s.name)} onclick={() => toggleSeries(s.name)}
title={m.chart_toggle_series({ series: s.name })} title="Toggle {s.name}"
> >
<span <span
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full" class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
@@ -2015,13 +1569,12 @@
<div <div
class="px-2 pt-2 pb-1 text-center md:text-right text-[10px] leading-none text-muted-foreground/70" class="px-2 pt-2 pb-1 text-center md:text-right text-[10px] leading-none text-muted-foreground/70"
> >
{m.footer_data_by()} Weather data by <a
<a
class="font-medium underline-offset-2 hover:text-foreground hover:underline" class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://open-meteo.com" href="https://open-meteo.com"
target="_blank" target="_blank"
rel="noopener noreferrer">Open-Meteo</a rel="noopener noreferrer">Open-Meteo</a
>, {m.chart_credit_viz()} >, visualisation by
<a <a
class="font-medium underline-offset-2 hover:text-foreground hover:underline" class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://drizz.li" href="https://drizz.li"
+10 -9
View File
@@ -33,19 +33,19 @@ export const CHART_COLORS = {
// ─── Utility: Detect column-type variables ─────────────────────────────────── // ─── Utility: Detect column-type variables ───────────────────────────────────
/** Units that should be rendered as bar/column charts instead of lines. */ /** Units that should be rendered as bar/column charts instead of lines. */
const COLUMN_UNITS = new Set(['mm', 'cm', 'in', 'inch', 'MJ/m²']); const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
/** /**
* Returns true if the given unit should be rendered as a bar chart. * Returns true if the given unit should be rendered as a bar chart.
*/ */
export function isColumnUnit(unit: string): boolean { export function isColumnUnit(unit: string): boolean {
return COLUMN_UNITS.has(unit.trim()); return COLUMN_UNITS.has(unit);
} }
// ─── Data Processing Helpers ───────────────────────────────────────────────── // ─── Data Processing Helpers ─────────────────────────────────────────────────
export interface AverageResult { export interface AverageResult {
average: (number | null)[]; average: number[];
averageCount: number[]; averageCount: number[];
} }
@@ -62,7 +62,7 @@ export function calculateAverage(
variable: string, variable: string,
timeLength: number timeLength: number
): AverageResult { ): AverageResult {
const totals = new Array<number>(timeLength).fill(0); const average = new Array<number>(timeLength).fill(0);
const averageCount = new Array<number>(timeLength).fill(0); const averageCount = new Array<number>(timeLength).fill(0);
for (const [model, values] of Object.entries(hourlyData)) { for (const [model, values] of Object.entries(hourlyData)) {
@@ -71,17 +71,18 @@ export function calculateAverage(
for (const [index, val] of (values as number[]).entries()) { for (const [index, val] of (values as number[]).entries()) {
if (val !== null && val !== undefined && isFinite(val)) { if (val !== null && val !== undefined && isFinite(val)) {
if (index >= timeLength) continue; average[index] += val;
totals[index] += val;
averageCount[index]++; averageCount[index]++;
} }
} }
} }
// Finalize average values // Finalize average values
const average = totals.map((total, i) => for (let i = 0; i < timeLength; i++) {
averageCount[i] > 0 ? Math.round((total / averageCount[i]) * 10) / 10 : null if (averageCount[i] > 0) {
); average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
}
}
return { average, averageCount }; return { average, averageCount };
} }
+1 -7
View File
@@ -8,16 +8,10 @@
export { export {
default as CanvasChart, default as CanvasChart,
setGroupHover, setGroupHover,
setGroupRange,
registerGroupMember,
groupRange, groupRange,
groupHover groupHover
} from './CanvasChart.svelte'; } from './CanvasChart.svelte';
export type { export type { ChartSeries } from './CanvasChart.svelte';
ChartAgreementPoint,
ChartAgreementStrip,
ChartSeries
} from './CanvasChart.svelte';
export { buildDaylightBands } from './bands'; export { buildDaylightBands } from './bands';
export type { DaylightBand } from './bands'; export type { DaylightBand } from './bands';
@@ -18,8 +18,6 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import * as m from '$lib/paraglide/messages';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
// ─── Props ────────────────────────────────────────────────────────────────── // ─── Props ──────────────────────────────────────────────────────────────────
@@ -95,7 +93,7 @@
> >
<path d="M21 12a9 9 0 1 1-6.219-8.56" /> <path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg> </svg>
<span class="sr-only">{m.charts_loading()}</span> <span class="sr-only">Loading charts...</span>
</div> </div>
</div> </div>
</div> </div>
+9 -17
View File
@@ -18,15 +18,12 @@
{/snippet} {/snippet}
</ChartToolbar> </ChartToolbar>
--> -->
<script lang="ts"> <script module lang="ts">
import * as m from '$lib/paraglide/messages'; export type { ExportableChart } from './downloadChartsPng';
</script>
import { <script lang="ts">
type ChartDownloadOptions, import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
type ChartExportItem,
type ExportableChart,
downloadChartsPng
} from './downloadChartsPng';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -34,11 +31,9 @@
interface Props { interface Props {
/** Chart components available for download (undefined entries are skipped) */ /** Chart components available for download (undefined entries are skipped) */
charts?: Array<ChartExportItem | ExportableChart | undefined | null>; charts?: Array<ExportableChart | undefined | null>;
/** Base file name for downloaded images (without extension) */ /** Base file name for downloaded images (without extension) */
fileName?: string; fileName?: string;
/** Optional title and shared legend drawn into the combined PNG. */
exportOptions?: ChartDownloadOptions;
/** Optional CSS class for the outer container */ /** Optional CSS class for the outer container */
class?: string; class?: string;
/** Slot for additional controls (switches, checkboxes, etc.) */ /** Slot for additional controls (switches, checkboxes, etc.) */
@@ -48,7 +43,6 @@
let { let {
charts = [], charts = [],
fileName = 'drizzli-chart', fileName = 'drizzli-chart',
exportOptions,
class: className = '', class: className = '',
controls controls
}: Props = $props(); }: Props = $props();
@@ -59,9 +53,7 @@
// ─── Computed ─────────────────────────────────────────────────────────────── // ─── Computed ───────────────────────────────────────────────────────────────
let hasCharts = $derived( let hasCharts = $derived(charts.some((chart) => chart != null));
charts.some((item) => item != null && ('chart' in item ? item.chart != null : true))
);
// ─── Download ─────────────────────────────────────────────────────────────── // ─── Download ───────────────────────────────────────────────────────────────
@@ -69,7 +61,7 @@
if (!hasCharts || downloading) return; if (!hasCharts || downloading) return;
downloading = true; downloading = true;
try { try {
await downloadChartsPng(charts, fileName, exportOptions); await downloadChartsPng(charts, fileName);
} finally { } finally {
setTimeout(() => { setTimeout(() => {
downloading = false; downloading = false;
@@ -95,7 +87,7 @@
class="toolbar-btn" class="toolbar-btn"
disabled={!hasCharts || downloading} disabled={!hasCharts || downloading}
onclick={handleDownload} onclick={handleDownload}
title={m.chart_download()} title="Download meteogram as PNG image"
> >
{#if downloading} {#if downloading}
<svg <svg
+6 -101
View File
@@ -17,17 +17,6 @@ export interface ChartExportItem {
title?: string; 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. */ /** Resolves the page background so exports match the current theme. */
function exportBackground(): string { function exportBackground(): string {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim(); const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
@@ -58,59 +47,18 @@ function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartE
*/ */
export async function downloadChartsPng( export async function downloadChartsPng(
charts: Array<ChartExportItem | ExportableChart | null | undefined>, charts: Array<ChartExportItem | ExportableChart | null | undefined>,
fileName: string, fileName: string
options: ChartDownloadOptions = {}
): Promise<void> { ): Promise<void> {
const items = charts const items = charts.map(toItem).filter((it) => it.chart != null);
.map(toItem)
.filter(
(it): it is ChartExportItem & { chart: ExportableChart } =>
typeof it.chart?.getExportImage === 'function'
);
if (items.length === 0) return; if (items.length === 0) return;
// A supplementary panel must never prevent the remaining charts from being const canvases = (
// downloaded. Invalid adapters are filtered above; rejected renders are await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title })))
// isolated here instead of aborting the complete Promise.all chain. ).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0);
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; if (canvases.length === 0) return;
const maxWidth = Math.max(...canvases.map((c) => c.width)); const maxWidth = Math.max(...canvases.map((c) => c.width));
const dpr = window.devicePixelRatio || 1; const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
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'); const canvas = document.createElement('canvas');
canvas.width = maxWidth; canvas.width = maxWidth;
@@ -122,54 +70,11 @@ export async function downloadChartsPng(
ctx.fillStyle = exportBackground(); ctx.fillStyle = exportBackground();
ctx.fillRect(0, 0, maxWidth, totalHeight); 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; let y = 0;
if (options.title) {
ctx.fillStyle = foreground;
ctx.font = `600 ${16 * dpr}px system-ui, -apple-system, sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillText(options.title, padding, 17 * dpr);
y += 34 * dpr;
}
for (const c of canvases) { for (const c of canvases) {
ctx.drawImage(c, 0, y); ctx.drawImage(c, 0, y);
y += c.height; 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`); triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
} }
+1 -7
View File
@@ -9,10 +9,4 @@
export { default as ChartContainer } from './ChartContainer.svelte'; export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.svelte'; export { default as ChartToolbar } from './ChartToolbar.svelte';
export { export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
downloadChartsPng,
type ChartDownloadOptions,
type ChartExportItem,
type ExportableChart,
type ExportLegendItem
} from './downloadChartsPng';
@@ -1,53 +0,0 @@
<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>
@@ -1,35 +0,0 @@
<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>
@@ -1,29 +0,0 @@
<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,8 +13,6 @@
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import * as m from '$lib/paraglide/messages';
export let label: string = 'Search location...'; export let label: string = 'Search location...';
export let placeholder: string = 'Enter city name...'; export let placeholder: string = 'Enter city name...';
@@ -50,12 +48,6 @@
); );
} }
/** 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) { function toggleFavorite(loc: GeoLocation) {
const key = locationKey(loc); const key = locationKey(loc);
storedFavoriteLocations.update((list) => storedFavoriteLocations.update((list) =>
@@ -134,7 +126,7 @@
})(); })();
</script> </script>
{#snippet locationRow(location: GeoLocation, removable: boolean)} {#snippet locationRow(location: GeoLocation)}
{@const fav = favKeys.has(locationKey(location))} {@const fav = favKeys.has(locationKey(location))}
<div <div
class="group flex items-center rounded-md border border-transparent transition-[background,border-color] duration-150 hover:border-border hover:bg-accent" class="group flex items-center rounded-md border border-transparent transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
@@ -163,8 +155,8 @@
? 'text-amber-500' ? 'text-amber-500'
: 'text-muted-foreground/50'}" : 'text-muted-foreground/50'}"
onclick={() => toggleFavorite(location)} onclick={() => toggleFavorite(location)}
aria-label={fav ? m.search_favorite_remove() : m.search_favorite_add()} aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
title={fav ? m.search_favorite_remove() : m.search_favorite_add()} title={fav ? 'Remove from favorites' : 'Add to favorites'}
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -180,18 +172,6 @@
/> />
</svg> </svg>
</button> </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> </div>
{/snippet} {/snippet}
@@ -233,7 +213,7 @@
class="h-9" class="h-9"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
aria-label={m.search_aria()} aria-label="Search Location"
bind:value={searchQuery} bind:value={searchQuery}
bind:ref={searchInputEl} bind:ref={searchInputEl}
/> />
@@ -242,7 +222,7 @@
variant="outline" variant="outline"
size="default" size="default"
class="h-9 px-2.5" class="h-9 px-2.5"
title={m.search_gps()} title="Use GPS Location"
onclick={() => (searchQuery = 'GPS')} onclick={() => (searchQuery = 'GPS')}
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -268,7 +248,7 @@
<div class="flex h-20 items-center justify-center"> <div class="flex h-20 items-center justify-center">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div> <div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
<span class="text-sm text-muted-foreground">{m.search_searching()}</span> <span class="text-sm text-muted-foreground">Searching...</span>
</div> </div>
</div> </div>
{:then results} {:then results}
@@ -278,11 +258,11 @@
<div <div
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase" class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
> >
{m.search_favorites()} Favorites
</div> </div>
<div class="space-y-0.5"> <div class="space-y-0.5">
{#each $storedFavoriteLocations as loc (locationKey(loc))} {#each $storedFavoriteLocations as loc (locationKey(loc))}
{@render locationRow(loc, false)} {@render locationRow(loc)}
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -292,11 +272,11 @@
? 'mt-3' ? 'mt-3'
: ''}" : ''}"
> >
{m.search_recent()} Recent
</div> </div>
<div class="space-y-0.5"> <div class="space-y-0.5">
{#each recentToShow as loc (locationKey(loc))} {#each recentToShow as loc (locationKey(loc))}
{@render locationRow(loc, true)} {@render locationRow(loc)}
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -318,14 +298,14 @@
/> />
</svg> </svg>
<span class="text-xs"> <span class="text-xs">
{m.search_hint()} Start typing to search or use GPS to detect your position
</span> </span>
</div> </div>
{/if} {/if}
{:else if results.results && results.results.length > 0} {:else if results.results && results.results.length > 0}
<div class="space-y-0.5"> <div class="space-y-0.5">
{#each results.results as location, i (i)} {#each results.results as location, i (i)}
{@render locationRow(location, false)} {@render locationRow(location)}
{/each} {/each}
</div> </div>
{:else if results.results} {:else if results.results}
@@ -338,7 +318,7 @@
</Alert.Root> </Alert.Root>
{:else} {:else}
<Alert.Root variant="destructive"> <Alert.Root variant="destructive">
<Alert.Description>{m.search_no_results()}</Alert.Description> <Alert.Description>No locations found</Alert.Description>
</Alert.Root> </Alert.Root>
{/if} {/if}
{:catch error} {:catch error}
-124
View File
@@ -1,124 +0,0 @@
<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>
+65 -65
View File
@@ -2,22 +2,17 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { type GeoLocation, type Theme, storedLocation, storedTheme } from '$lib/stores/settings'; import { type GeoLocation, type Theme, storedLocation, storedTheme } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import LanguageSelector from '$lib/components/language-selector.svelte';
import LocationSearch from '$lib/components/location/location-search.svelte'; import LocationSearch from '$lib/components/location/location-search.svelte';
import UnitSelector from '$lib/components/unit-selector.svelte'; import UnitSelector from '$lib/components/unit-selector.svelte';
import { href, routePath } from '$lib/i18n'; import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
import * as m from '$lib/paraglide/messages';
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
import SettingsMenu from './settings-menu.svelte';
import ThemeIcon from './theme-icon.svelte';
interface Props { interface Props {
onMenuToggle?: () => void; onMenuToggle?: () => void;
@@ -31,21 +26,11 @@
location = value; 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;
});
const themeCycle: Theme[] = ['system', 'light', 'dark']; const themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, () => string> = { const themeTitles: Record<Theme, string> = {
system: m.theme_follow_system, system: 'Theme: follow system',
light: m.theme_light_title, light: 'Theme: light',
dark: m.theme_dark_title dark: 'Theme: dark'
}; };
function cycleTheme() { function cycleTheme() {
@@ -57,18 +42,16 @@
function navigateToLocation(newLocation: GeoLocation) { function navigateToLocation(newLocation: GeoLocation) {
storedLocation.set(newLocation); storedLocation.set(newLocation);
const locationRoute = buildLocationRoute(newLocation); const locationRoute = buildLocationRoute(newLocation);
const currentPath = routePath(get(page).url.pathname); const currentPath = get(page).url.pathname;
if (currentPath.startsWith('/weather/compare')) { if (currentPath.startsWith('/weather/compare')) {
goto(href('/weather/compare/[location]', { location: locationRoute })); goto(resolve('/weather/compare/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/14-day')) { } else if (currentPath.startsWith('/weather/14-day')) {
goto(href('/weather/14-day/[location]', { location: locationRoute })); goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/historical')) { } else if (currentPath.startsWith('/weather/historical')) {
goto(href('/weather/historical/[location]', { location: locationRoute })); goto(resolve('/weather/historical/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/seasonal')) {
goto(href('/weather/seasonal/[location]', { location: locationRoute }));
} else { } else {
goto(href('/weather/week/[location]', { location: locationRoute })); goto(resolve('/weather/week/[location]', { location: locationRoute }));
} }
} }
</script> </script>
@@ -76,26 +59,16 @@
<header <header
class="topbar flex h-14 shrink-0 items-center gap-3 border-b border-topbar-border bg-topbar px-3 md:px-4" 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. On phones this side and the settings side both take <!-- Mobile menu toggle -->
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 <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" 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} onclick={onMenuToggle}
aria-label={m.nav_toggle_menu()} aria-label="Toggle menu"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
> >
<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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
</button> </button>
</div>
<!-- Current location display --> <!-- Current location display -->
{#if location} {#if location}
@@ -103,7 +76,6 @@
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 items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
> >
<img <img
bind:this={flagEl}
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border" class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg" src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country} alt={location.country}
@@ -121,48 +93,76 @@
</div> </div>
{/if} {/if}
<!-- Spacer (md+ only: on phones the equal side columns centre the search) --> <!-- Spacer -->
<div class="hidden flex-1 md:block"></div> <div class="flex-1"></div>
<!-- Location search: primary way to switch places, so keep it loud --> <!-- Location search: primary way to switch places, so keep it loud -->
<div class="w-full max-w-sm md:max-w-md"> <div class="w-full max-w-sm md:max-w-md">
<LocationSearch <LocationSearch
label={m.search_placeholder()} label="Search location..."
on:location={(event) => { on:location={(event) => {
navigateToLocation(event.detail); navigateToLocation(event.detail);
}} }}
/> />
</div> </div>
<!-- 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>
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
so SupporterBadge still verifies the key on every viewport. -->
<div class="hidden items-center gap-3 md:flex">
<!-- Supporter status / unlock -->
<SupporterBadge />
<!-- Language: the locale lives in the URL, so this is a set of links -->
<LanguageSelector />
<!-- Measurement units --> <!-- Measurement units -->
<UnitSelector /> <UnitSelector />
<!-- Premium status / unlock -->
<PremiumBadge />
<!-- Theme toggle: system → light → dark --> <!-- Theme toggle: system → light → dark -->
<button <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" 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} onclick={cycleTheme}
title={themeTitles[$storedTheme]()} title={themeTitles[$storedTheme]}
aria-label={themeTitles[$storedTheme]()} aria-label={themeTitles[$storedTheme]}
> >
<ThemeIcon theme={$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>
{/if}
</button> </button>
</div>
</header> </header>
<style> <style>
@@ -1,21 +0,0 @@
<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>
@@ -1,106 +0,0 @@
<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/paywall/SupporterIcon.svelte';
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
import { isSupporter } from '$lib/paywall/supporter';
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)} />
<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>
</div>
</Popover.Content>
</Popover.Root>
<UnlockDialog bind:open={unlockOpen} />
@@ -1,36 +0,0 @@
<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,15 +1,7 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/stores'; 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 { interface Props {
collapsed?: boolean; collapsed?: boolean;
onToggle?: () => void; onToggle?: () => void;
@@ -20,43 +12,32 @@
const links = [ const links = [
{ {
title: m.nav_week, title: '7-Day Forecast',
url: '/weather/week' as const, url: '/weather/week' as const,
route: '/weather/week/[location]' as const,
iconPaths: [ 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' '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: m.nav_compare, title: 'Model Comparison',
url: '/weather/compare' as const, url: '/weather/compare' as const,
route: '/weather/compare/[location]' as const,
iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6'] iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6']
}, },
{ {
title: m.nav_14day, title: '14-Day Forecast',
url: '/weather/14-day' as const, url: '/weather/14-day' as const,
route: '/weather/14-day/[location]' as const,
iconPaths: [ 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' '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: m.nav_seasonal, title: 'Historical',
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, url: '/weather/historical' as const,
route: '/weather/historical/[location]' as const,
// clock with a counter-clockwise arrow (history) // 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'] iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
}, },
{ {
title: m.nav_maps, title: 'Maps',
url: '/weather/maps' as const, url: '/weather/maps' as const,
// Heroicons "map" outline icon // Heroicons "map" outline icon
iconPaths: [ iconPaths: [
@@ -65,13 +46,7 @@
} }
]; ];
// the URL carries a locale prefix; compare the neutral path behind it let currentPath = $derived($page.url.pathname);
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) => { const isActive = (url: string) => {
return currentPath === url || currentPath.startsWith(url + '/'); return currentPath === url || currentPath.startsWith(url + '/');
@@ -87,17 +62,34 @@
home link fills the entire row, padding included --> home link fills the entire row, padding included -->
<div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border"> <div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border">
<a <a
href={href('/weather/week/[location]', { location: locationRoute })} href={resolve('/weather/week')}
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
? 'justify-center' ? 'justify-center'
: 'px-4'}" : 'px-4'}"
onclick={onMobileClose} onclick={onMobileClose}
aria-label={m.nav_home()} aria-label="Drizzli home"
> >
<div <div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
> >
<LogoMark /> <!-- 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>
</div> </div>
{#if !collapsed} {#if !collapsed}
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground"> <span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
@@ -109,14 +101,14 @@
<!-- Navigation links --> <!-- Navigation links -->
<nav class="flex-1 space-y-1 px-2 py-3"> <nav class="flex-1 space-y-1 px-2 py-3">
{#each links as link (link.url)} {#each links as link (link.title)}
{@const active = isActive(link.url)} {@const active = isActive(link.url)}
<a <a
href={link.route ? href(link.route, { location: locationRoute }) : href(link.url)} href={resolve(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 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' ? 'bg-sidebar-accent text-sidebar-primary! opacity-100! font-semibold! nav-active'
: ''}" : ''}"
title={collapsed ? link.title() : undefined} title={collapsed ? link.title : undefined}
onclick={onMobileClose} onclick={onMobileClose}
> >
<div class="flex h-5 w-5 shrink-0 items-center justify-center"> <div class="flex h-5 w-5 shrink-0 items-center justify-center">
@@ -133,49 +125,19 @@
</svg> </svg>
</div> </div>
{#if !collapsed} {#if !collapsed}
<span class="ml-2.5 whitespace-nowrap">{link.title()}</span> <span class="ml-2.5 whitespace-nowrap">{link.title}</span>
{/if} {/if}
</a> </a>
{/each} {/each}
</nav> </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) --> <!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
{#if onToggle} {#if onToggle}
<div class="border-t border-sidebar-border px-2 py-3"> <div class="border-t border-sidebar-border px-2 py-3">
<button <button
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" 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} onclick={onToggle}
title={collapsed ? m.nav_expand_sidebar() : m.nav_collapse_sidebar()} title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
> >
<div class="flex h-5 w-5 shrink-0 items-center justify-center"> <div class="flex h-5 w-5 shrink-0 items-center justify-center">
<svg <svg
@@ -194,7 +156,7 @@
</svg> </svg>
</div> </div>
{#if !collapsed} {#if !collapsed}
<span class="ml-2.5 whitespace-nowrap">{m.nav_collapse()}</span> <span class="ml-2.5 whitespace-nowrap">Collapse</span>
{/if} {/if}
</button> </button>
</div> </div>
-77
View File
@@ -1,77 +0,0 @@
<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>
-69
View File
@@ -1,69 +0,0 @@
<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>
+75 -6
View File
@@ -1,15 +1,60 @@
<script lang="ts"> <script lang="ts">
import * as Popover from '$lib/components/ui/popover'; import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
import UnitOptions from '$lib/components/unit-options.svelte';
import * as m from '$lib/paraglide/messages'; import * as Popover from '$lib/components/ui/popover';
// 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' }
]
},
{
key: 'time_format',
label: 'Time',
options: [
{ value: '24h', label: '24h' },
{ value: '12h', label: 'AM/PM' }
]
}
];
function setUnit(key: keyof UnitPrefs, value: string) {
storedUnits.update((u) => ({ ...u, [key]: value }));
}
</script> </script>
<Popover.Root> <Popover.Root>
<Popover.Trigger <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" 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()} aria-label="Choose measurement units"
title={m.units_title()} title="Units"
> >
<!-- gauge icon --> <!-- gauge icon -->
<svg <svg
@@ -28,6 +73,30 @@
</svg> </svg>
</Popover.Trigger> </Popover.Trigger>
<Popover.Content align="end" class="w-64 border-border"> <Popover.Content align="end" class="w-64 border-border">
<UnitOptions /> <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>
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>
-81
View File
@@ -1,81 +0,0 @@
/**
* 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;
+723
View File
@@ -0,0 +1,723 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import LoaderIcon from '@lucide/svelte/icons/loader-circle';
import PauseIcon from '@lucide/svelte/icons/pause';
import PlayIcon from '@lucide/svelte/icons/play';
import { updateCurrentBounds } from '@openmeteo/weather-map-layer';
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { FrameAnimator, type FrameChannel } from '$lib/maps/frame-animator';
import {
clockPlaying,
clockScrub,
clockSpan,
clockSpeed,
sharedCamera
} from '$lib/maps/map-sync';
import {
type ResolvedSource,
omSourceUrl,
registerOmProtocol,
resolveSource
} from '$lib/maps/om';
import { modelGroups } from '../../routes/weather/options';
interface Props {
/** om-file variable to render, e.g. `precipitation`, `wind_u_component_10m`. */
variable: string;
/** Human label for the status chip / errors, e.g. "Precipitation", "Wind". */
title: string;
/**
* Accumulation variable (e.g. precipitation): its first timestep is the
* analysis time with no accumulation window, so skip it. Leave off for
* instantaneous variables like wind.
*/
accumulation?: boolean;
/** Overlay animated wind-arrow vectors on top of the raster field. */
arrows?: boolean;
}
let { variable, title, accumulation = false, arrows = false }: Props = $props();
const STYLE_LIGHT = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps.json';
const STYLE_DARK = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps-dark.json';
const DAY_MS = 86_400_000;
// ~8 frames loading at once ≈ 50 concurrent tile requests (a frame is a few
// tiles) - the requested preload parallelism.
const PRELOAD_MAX_FRAMES = 8;
// Playback speeds: ms of minimum wall-time per forecast frame.
const SPEEDS = [
{ label: '0.5×', ms: 900 },
{ label: '1×', ms: 450 },
{ label: '2×', ms: 220 }
];
// Loop / preload span presets (Infinity = the complete model run).
const SPANS = [
{ label: '2½ d', days: 2.5 },
{ label: '5 d', days: 5 },
{ label: 'Full run', days: Infinity }
];
const modelLabel = (id: string): string => {
for (const g of modelGroups) {
for (const m of g.models) if (m.value === id) return m.label;
}
return id;
};
let mapEl: HTMLDivElement;
let map: maplibregl.Map | undefined;
let animator: FrameAnimator | undefined;
let marker: maplibregl.Marker | undefined;
let syncingCamera = false;
// resolved source (full run) + windowed timeline
let sourceBase: ResolvedSource | null = null;
let fullTimes: Date[] = [];
let source = $state<ResolvedSource | null>(null);
let index = $state(0);
let loading = $state(true);
let firstFrameLoading = $state(true);
let error = $state<string | null>(null);
/** Per-frame load state, aligned with `times`. */
let loaded = $state<boolean[]>([]);
// dark mode mirrors the root layout's `.dark` class on <html>
let dark = $state(false);
// preload covers the whole windowed timeline (the loop == the window)
let loopStart = 0;
let loopEnd = 0;
let loadController: AbortController | undefined;
let lastKey = '';
let appliedSpan = get(clockSpan);
let appliedScrubEpoch = get(clockScrub).epoch;
const times = $derived(source?.times ?? []);
const currentTime = $derived(times[index]);
const loadedCount = $derived(loaded.reduce((n, v) => n + (v ? 1 : 0), 0));
// ---- helpers -------------------------------------------------------------
const closestIndexToNow = (list: Date[]): number => {
const now = Date.now();
let best = 0;
let bestDelta = Infinity;
for (let i = 0; i < list.length; i++) {
const delta = Math.abs(list[i].getTime() - now);
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
return best;
};
/** Keep steps within `spanDays` days from today 00:00 (Infinity = all). */
const windowTimes = (all: Date[], spanDays: number): Date[] => {
if (!isFinite(spanDays) || spanDays <= 0) return all;
const dayStart = new SvelteDate();
dayStart.setHours(0, 0, 0, 0);
const cutoff = dayStart.getTime() + spanDays * DAY_MS;
const windowed = all.filter((t) => t.getTime() >= dayStart.getTime() && t.getTime() <= cutoff);
return windowed.length ? windowed : all;
};
const buildUrl = (i: number): string => {
const s = source;
if (!s) return '';
return omSourceUrl(s.domain, s.modelRun, s.times[i], variable, dark, arrows);
};
const firstSymbolLayer = (m: maplibregl.Map): string | undefined =>
m.getStyle()?.layers?.find((l) => l.type === 'symbol')?.id;
// ---- animator / channels -------------------------------------------------
const makeChannels = (): FrameChannel[] => {
const raster: FrameChannel = {
key: 'raster',
sourceSpec: (url) => ({ type: 'raster', url, maxzoom: 14 }),
addLayer: (m, sourceId, layerId, before) =>
m.addLayer(
{
id: layerId,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': 0,
'raster-opacity-transition': { duration: 0, delay: 0 },
'raster-fade-duration': 0
}
},
before
),
opacityProp: 'raster-opacity',
peakOpacity: dark ? 0.85 : 0.9
};
if (!arrows) return [raster];
const arrowLayer: FrameChannel = {
key: 'arrows',
sourceSpec: (url) => ({ type: 'vector', url }),
addLayer: (m, sourceId, layerId, before) =>
m.addLayer(
{
id: layerId,
type: 'line',
source: sourceId,
'source-layer': 'wind-arrows',
layout: { 'line-cap': 'round' },
paint: {
'line-opacity': 0,
'line-opacity-transition': { duration: 0, delay: 0 },
'line-color': dark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.72)',
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.2, 9, 1.8]
}
},
before
),
opacityProp: 'line-opacity',
peakOpacity: 1
};
return [raster, arrowLayer];
};
const makeAnimator = (m: maplibregl.Map): void => {
animator?.destroy();
animator = new FrameAnimator({
map: m,
urlForFrame: buildUrl,
channels: makeChannels(),
beforeLayer: firstSymbolLayer(m),
opacity: dark ? 0.85 : 0.9,
onShow: (i) => {
index = i;
if (i < loaded.length) loaded[i] = true;
},
onReady: (i) => {
if (i < loaded.length) loaded[i] = true;
},
onFirstFrame: () => {
firstFrameLoading = false;
}
});
};
/** Chronological preload so the current day fills front-to-back, no gap. */
const preloadOrder = (): number[] => {
const order: number[] = [];
for (let i = loopStart; i <= loopEnd; i++) order.push(i);
return order;
};
const startAnimation = (m: maplibregl.Map): void => {
if (!source) return;
makeAnimator(m);
animator!.setFrameCount(source.times.length);
animator!.setLoopRange(loopStart, loopEnd);
const speed = get(clockSpeed);
animator!.setSpeed(speed);
const initial = closestIndexToNow(source.times);
index = initial;
if (get(clockPlaying)) animator!.play(speed, initial);
else animator!.scrubTo(initial);
animator!.preload(preloadOrder(), PRELOAD_MAX_FRAMES);
};
// ---- windowing / (re)load ------------------------------------------------
const applyWindow = (): void => {
if (!sourceBase || !map) return;
const list = windowTimes(fullTimes, get(clockSpan));
source = { ...sourceBase, times: list };
loopStart = 0;
loopEnd = Math.max(0, list.length - 1);
loaded = new Array(list.length).fill(false);
firstFrameLoading = true;
startAnimation(map);
};
const load = async (model: string): Promise<void> => {
loadController?.abort();
const controller = new AbortController();
loadController = controller;
loading = true;
error = null;
const resolved = await resolveSource(model, variable, {
signal: controller.signal,
skipFirst: accumulation
});
if (controller.signal.aborted) return;
if (!resolved) {
error = `No ${title.toLowerCase()} data available for this model right now.`;
loading = false;
return;
}
sourceBase = resolved;
fullTimes = resolved.times;
loading = false;
applyWindow();
};
// ---- theme ---------------------------------------------------------------
const applyStyle = async (m: maplibregl.Map): Promise<void> => {
m.setStyle(dark ? STYLE_DARK : STYLE_LIGHT);
await new Promise<void>((resolve) => m.once('style.load', () => resolve()));
applyPadding(m);
// setStyle wipes all layers; rebuild the animator for the new theme
if (source) startAnimation(m);
};
// ---- camera / marker -----------------------------------------------------
// 20% bottom padding keeps the focused location above the time scrubber.
const applyPadding = (m: maplibregl.Map): void => {
const h = mapEl?.clientHeight ?? 0;
m.setPadding({ top: 0, right: 0, bottom: Math.round(h * 0.2), left: 0 });
};
const makeMarkerEl = (): HTMLDivElement => {
const el = document.createElement('div');
el.className = 'wx-marker';
el.innerHTML = '<span class="wx-marker__ring"></span><span class="wx-marker__dot"></span>';
return el;
};
// ---- lifecycle -----------------------------------------------------------
onMount(() => {
registerOmProtocol();
dark = document.documentElement.classList.contains('dark');
lastKey = `${$storedModel}`;
map = new maplibregl.Map({
container: mapEl,
style: dark ? STYLE_DARK : STYLE_LIGHT,
center: [$storedLocation.longitude, $storedLocation.latitude],
zoom: 7.5,
attributionControl: { compact: true },
maxPitch: 0,
dragRotate: false
});
const m = map;
m.scrollZoom.disable(); // don't hijack page scroll to zoom
m.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
const pushBounds = () => {
const b = m.getBounds();
updateCurrentBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
};
// mirror this map's camera to siblings (unless the move came from a sync)
m.on('move', () => {
pushBounds();
if (syncingCamera) return;
const c = m.getCenter();
sharedCamera.set({ center: [c.lng, c.lat], zoom: m.getZoom() });
});
m.on('resize', () => applyPadding(m));
m.on('load', () => {
pushBounds();
applyPadding(m);
marker = new maplibregl.Marker({ element: makeMarkerEl(), anchor: 'center' })
.setLngLat([$storedLocation.longitude, $storedLocation.latitude])
.addTo(m);
void load($storedModel);
});
// follow a sibling map's camera
const unsubCamera = sharedCamera.subscribe((cam) => {
if (!cam || !map) return;
const c = map.getCenter();
if (
Math.abs(c.lng - cam.center[0]) < 1e-6 &&
Math.abs(c.lat - cam.center[1]) < 1e-6 &&
Math.abs(map.getZoom() - cam.zoom) < 1e-6
)
return;
syncingCamera = true;
map.jumpTo({ center: cam.center, zoom: cam.zoom });
syncingCamera = false;
});
// keep dark in sync with the app theme toggle (root layout flips `.dark`)
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('dark');
if (isDark !== dark) {
dark = isDark;
if (map && map.isStyleLoaded()) void applyStyle(map);
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
observer.disconnect();
unsubCamera();
animator?.destroy();
loadController?.abort();
marker?.remove();
map?.remove();
map = undefined;
};
});
// reload when the shared model selection changes
$effect(() => {
const key = `${$storedModel}`;
if (map && map.isStyleLoaded() && key !== lastKey) {
lastKey = key;
void load($storedModel);
}
});
// recenter + move the marker when the user picks a new location
$effect(() => {
const loc = $storedLocation;
marker?.setLngLat([loc.longitude, loc.latitude]);
if (map && map.isStyleLoaded()) {
map.easeTo({ center: [loc.longitude, loc.latitude], duration: 800 });
}
});
// ---- shared-clock effects ------------------------------------------------
// play / pause together
$effect(() => {
const playing = $clockPlaying;
if (!animator) return;
if (playing) {
const from = index >= loopStart && index <= loopEnd ? index : loopStart;
animator.play(get(clockSpeed), from);
} else {
animator.pause();
}
});
// speed together
$effect(() => {
const ms = $clockSpeed;
animator?.setSpeed(ms);
});
// span together (re-window + restart)
$effect(() => {
const days = $clockSpan;
if (days !== appliedSpan) {
appliedSpan = days;
if (sourceBase) applyWindow();
}
});
// scrub together
$effect(() => {
const s = $clockScrub;
if (s.epoch !== appliedScrubEpoch) {
appliedScrubEpoch = s.epoch;
if (s.index >= 0) {
index = s.index;
animator?.scrubTo(s.index);
}
}
});
// ---- controls (write to the shared clock so every map follows) -----------
const togglePlay = (): void => clockPlaying.update((p) => !p);
const setSpeed = (ms: number): void => clockSpeed.set(ms);
const setSpan = (days: number): void => clockSpan.set(days);
const onScrubInput = (e: Event): void => {
const v = Number((e.currentTarget as HTMLInputElement).value);
clockScrub.set({ index: v, epoch: get(clockScrub).epoch + 1 });
};
const onScrubStart = (): void => clockPlaying.set(false);
const onScrubEnd = (): void => clockPlaying.set(true);
// ---- formatting / scrubber decorations -----------------------------------
const hour12 = $derived($storedUnits.time_format === '12h');
const fmtTime = (d: Date | undefined): string =>
d
? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12 }).format(d)
: '--:--';
const fmtDay = (d: Date | undefined): string =>
d
? new Intl.DateTimeFormat(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short'
}).format(d)
: '';
const pct = (i: number): number => (times.length > 1 ? (i / (times.length - 1)) * 100 : 0);
const dayMarkers = $derived.by(() => {
const out: { i: number; label: string; left: number }[] = [];
let prevDay = '';
times.forEach((t, i) => {
const day = t.toDateString();
if (day !== prevDay) {
prevDay = day;
out.push({
i,
label: new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(t),
left: pct(i)
});
}
});
return out;
});
const nowLeft = $derived(times.length ? pct(closestIndexToNow(times)) : 0);
</script>
<div class="relative h-full w-full">
<div bind:this={mapEl} class="h-full w-full"></div>
<!-- top-left status: variable + model + fallback notice -->
<div class="pointer-events-none absolute left-3 top-3 z-10 flex flex-col gap-1">
<div
class="pointer-events-auto rounded-md bg-background/85 px-3 py-1.5 text-sm font-medium shadow-sm backdrop-blur"
>
{title} · {modelLabel($storedModel)}
</div>
{#if source?.fellBack}
<div
class="pointer-events-auto w-fit rounded-md bg-amber-500/90 px-2 py-1 text-xs font-medium text-white shadow-sm"
>
No map for this model - showing global {source.domain}
</div>
{/if}
</div>
{#if error}
<div class="absolute inset-x-0 top-16 z-10 flex justify-center">
<div class="rounded-md bg-destructive px-4 py-2 text-sm text-destructive-foreground shadow">
{error}
</div>
</div>
{/if}
<!-- big scrubber -->
<div class="absolute inset-x-0 bottom-0 z-10 p-3 sm:p-4">
<div
class="mx-auto w-full rounded-xl border border-border bg-background/90 p-3 shadow-lg backdrop-blur sm:p-4"
>
<div class="flex items-center gap-3 sm:gap-4">
<button
type="button"
onclick={togglePlay}
disabled={loading || !!error || times.length < 2}
aria-label={$clockPlaying ? 'Pause' : 'Play'}
class="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition hover:opacity-90 disabled:opacity-40 sm:size-14"
>
{#if loading || firstFrameLoading}
<LoaderIcon class="size-6 animate-spin" />
{:else if $clockPlaying}
<PauseIcon class="size-6" />
{:else}
<PlayIcon class="size-6 translate-x-0.5" />
{/if}
</button>
<div class="flex min-w-0 flex-col">
<span class="text-2xl font-semibold leading-none tabular-nums sm:text-3xl">
{fmtTime(currentTime)}
</span>
<span class="mt-1 truncate text-xs text-muted-foreground sm:text-sm">
{fmtDay(currentTime)}
</span>
</div>
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
{#if times.length}
<span class="hidden text-xs text-muted-foreground tabular-nums xl:inline">
{loadedCount}/{times.length} cached
</span>
{/if}
<!-- range: how much to loop / preload -->
<div
class="flex items-center gap-1 rounded-lg bg-muted p-1"
title="Loop &amp; preload range"
>
{#each SPANS as s (s.label)}
<button
type="button"
onclick={() => setSpan(s.days)}
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
{$clockSpan === s.days
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{s.label}
</button>
{/each}
</div>
<!-- playback speed -->
<div class="flex items-center gap-1 rounded-lg bg-muted p-1" title="Playback speed">
{#each SPEEDS as s (s.ms)}
<button
type="button"
onclick={() => setSpeed(s.ms)}
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
{$clockSpeed === s.ms
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{s.label}
</button>
{/each}
</div>
</div>
</div>
<!-- track -->
<div class="relative mt-3">
<input
type="range"
class="scrubber relative"
min="0"
max={Math.max(0, times.length - 1)}
step="1"
value={index}
disabled={loading || !!error || !times.length}
oninput={onScrubInput}
onpointerdown={onScrubStart}
onpointerup={onScrubEnd}
onpointercancel={onScrubEnd}
aria-label="Forecast time"
/>
<!-- now marker -->
{#if times.length}
<div
class="pointer-events-none absolute -top-1 h-3 w-0.5 bg-red-500"
style="left: {nowLeft}%"
title="Now"
></div>
{/if}
<!-- buffered-frames strip: shows which steps are cached -->
{#if times.length}
<div class="mt-2 flex h-1.5 w-full overflow-hidden rounded-full bg-muted-foreground/15">
{#each times as _t, i (i)}
<div
class="h-full flex-1 transition-colors {loaded[i]
? 'bg-primary'
: 'bg-transparent'} {i === index ? 'bg-red-500' : ''}"
></div>
{/each}
</div>
{/if}
<!-- day labels -->
<div class="relative mt-1 h-4">
{#each dayMarkers as d (d.i)}
<span
class="pointer-events-none absolute -translate-x-1/2 text-[10px] text-muted-foreground"
style="left: {Math.min(96, Math.max(4, d.left))}%"
>
{d.label}
</span>
{/each}
</div>
</div>
</div>
</div>
</div>
<style>
/* Big, prominent scrubber that works in light and dark. */
.scrubber {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 10px;
border-radius: 9999px;
background: color-mix(in oklab, var(--color-primary) 25%, transparent);
cursor: pointer;
outline: none;
}
.scrubber:disabled {
opacity: 0.5;
cursor: default;
}
.scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 26px;
height: 26px;
border-radius: 9999px;
background: var(--color-primary);
border: 3px solid var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: grab;
}
.scrubber::-webkit-slider-thumb:active {
cursor: grabbing;
}
.scrubber::-moz-range-thumb {
width: 26px;
height: 26px;
border-radius: 9999px;
background: var(--color-primary);
border: 3px solid var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: grab;
}
.scrubber::-moz-range-progress {
height: 10px;
border-radius: 9999px;
background: var(--color-primary);
}
/* Pulsing "current location" marker (added to the map's own DOM, so styles
must be global). */
:global(.wx-marker) {
position: relative;
width: 20px;
height: 20px;
pointer-events: none;
}
:global(.wx-marker__dot) {
position: absolute;
inset: 6px;
border-radius: 9999px;
background: #ef4444;
box-shadow:
0 0 0 2px #fff,
0 1px 3px rgba(0, 0, 0, 0.5);
}
:global(.wx-marker__ring) {
position: absolute;
inset: 0;
border-radius: 9999px;
background: rgba(239, 68, 68, 0.55);
animation: wx-pulse 1.5s ease-out infinite;
}
@keyframes -global-wx-pulse {
0% {
transform: scale(0.4);
opacity: 0.8;
}
100% {
transform: scale(2);
opacity: 0;
}
}
</style>
+379
View File
@@ -0,0 +1,379 @@
import * as maplibregl from 'maplibre-gl';
/**
* Smooth cross-fade animator for a stack of MapLibre weather frames.
*
* Instead of tearing down and rebuilding a source per timestep (which flashes
* and stutters), each forecast step gets its own persistent layer(s). Playback
* drives a floating-point playhead and holds each frame crisp for most of its
* dwell, then cross-fades to the next over a short window - a fluid hand-off
* rather than a muddy full-interval dissolve.
*
* A frame can render several **channels** (e.g. a raster field plus a vector
* wind-arrows overlay); all a frame's channels fade together and it is only
* "ready" once every channel's source has loaded.
*
* Frames inside the active loop range stay resident so repeat loops are
* instant; frames visited only by scrubbing are evicted LRU. The playhead only
* advances once both the current and next frame have loaded, so a slow tile
* never produces a gap - it just briefly holds the current frame.
*/
export interface FrameChannel {
/** Unique per channel (part of source/layer ids). */
key: string;
sourceSpec: (url: string) => maplibregl.SourceSpecification;
/** Add the layer at opacity 0 (it is faded in by the animator). */
addLayer: (
map: maplibregl.Map,
sourceId: string,
layerId: string,
beforeLayer: string | undefined
) => void;
/** Paint property used to fade this channel, e.g. `raster-opacity`. */
opacityProp: string;
/** Peak opacity for this channel (defaults to the animator's `opacity`). */
peakOpacity?: number;
}
export interface FrameAnimatorOptions {
map: maplibregl.Map;
/** `om://` source URL for frame `i`. */
urlForFrame: (i: number) => string;
channels: FrameChannel[];
beforeLayer?: string;
/** Default peak opacity for channels that don't set their own. */
opacity?: number;
/** Cross-fade window in ms (0 = instant swaps). Default 130. */
crossFadeMs?: number;
/** LRU cap for frames outside the loop range (scrub-visited). */
outOfRangePool?: number;
/** Fired when the dominant integer frame changes (drives the scrubber). */
onShow?: (i: number) => void;
/** Fired when frame `i` finishes loading (drives the buffer strip). */
onReady?: (i: number) => void;
/** Fired once, when the very first frame becomes visible. */
onFirstFrame?: () => void;
}
interface LayerInfo {
layerId: string;
opacityProp: string;
peak: number;
}
interface Entry {
sources: string[];
layers: LayerInfo[];
loadedSources: Set<string>;
ready: boolean;
onData?: (e: maplibregl.MapSourceDataEvent) => void;
}
export class FrameAnimator {
private map: maplibregl.Map;
private opt: FrameAnimatorOptions;
private target: number;
private crossFadeMs: number;
private frames = new Map<number, Entry>();
private visible = new Set<number>();
private lru: number[] = []; // out-of-loop frames, oldest first
private frameCount = 0;
private loopStart = 0;
private loopEnd = 0;
private mode: 'still' | 'play' = 'still';
private q = 0; // loop-local playhead (float) when playing
private stillIndex = 0;
private speedMs = 450;
private raf = 0;
private lastTs = 0;
private lastShown = -1;
private firstShown = false;
private alive = true;
// bounded-parallel preloading
private preloadQueue: number[] = [];
private preloading = new Set<number>();
private preloadInFlight = 0;
private preloadMax = 8;
constructor(opt: FrameAnimatorOptions) {
this.map = opt.map;
this.opt = opt;
this.target = opt.opacity ?? 0.9;
this.crossFadeMs = opt.crossFadeMs ?? 200;
this.tick = this.tick.bind(this);
}
setFrameCount(n: number): void {
this.frameCount = n;
}
setLoopRange(start: number, end: number): void {
this.loopStart = Math.max(0, start);
this.loopEnd = Math.max(this.loopStart, end);
}
setSpeed(ms: number): void {
this.speedMs = ms;
}
get currentIndex(): number {
return this.mode === 'play'
? this.loopStart + (Math.floor(this.q) % this.loopLen())
: this.stillIndex;
}
private loopLen(): number {
return Math.max(1, this.loopEnd - this.loopStart + 1);
}
private inLoop(i: number): boolean {
return i >= this.loopStart && i <= this.loopEnd;
}
/** Show a single frame and stop animating (used for scrubbing / paused). */
scrubTo(i: number): void {
this.mode = 'still';
this.stillIndex = Math.max(0, Math.min(this.frameCount - 1, i));
this.start();
}
/** Start looping over the loop range, entering at `fromIndex` when given. */
play(speedMs: number, fromIndex?: number): void {
this.speedMs = speedMs;
this.mode = 'play';
const L = this.loopLen();
this.q = fromIndex != null && this.inLoop(fromIndex) ? (fromIndex - this.loopStart) % L : 0;
this.start();
}
pause(): void {
if (this.mode === 'play') this.stillIndex = this.currentIndex;
this.mode = 'still';
}
private start(): void {
if (!this.alive) return;
this.lastTs = 0;
if (!this.raf) this.raf = requestAnimationFrame(this.tick);
}
private stopRaf(): void {
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
private tick(ts: number): void {
if (!this.alive) return;
const dt = this.lastTs ? ts - this.lastTs : 16;
this.lastTs = ts;
if (this.mode === 'play') this.tickPlay(dt);
else this.tickStill();
this.raf = requestAnimationFrame(this.tick);
}
private tickStill(): void {
const i = this.stillIndex;
this.ensure(i);
if (!this.frames.get(i)?.ready) return; // hold previous frame until ready
this.markFirst();
this.setOp(i, 1);
this.hideExcept(new Set([i]));
this.emitShown(i);
}
private tickPlay(dt: number): void {
const L = this.loopLen();
const baseLocal = Math.floor(this.q) % L;
const curr = this.loopStart + baseLocal;
const next = this.loopStart + ((baseLocal + 1) % L);
this.ensure(curr);
this.ensure(next);
this.ensure(this.loopStart + ((baseLocal + 2) % L));
const currReady = this.frames.get(curr)?.ready;
const nextReady = this.frames.get(next)?.ready;
if (currReady) this.markFirst();
if (currReady && nextReady) {
this.q += dt / this.speedMs;
if (this.q >= L) this.q -= L;
const bl = Math.floor(this.q) % L;
const cB = this.loopStart + bl;
const cN = this.loopStart + ((bl + 1) % L);
const frac = this.q - Math.floor(this.q);
// Hold the current frame crisp for most of its dwell, then cross-fade
// to the next only over the short crossFadeMs window at the end.
const fadeFrac = Math.min(0.9, this.crossFadeMs / this.speedMs);
const fadeStart = 1 - fadeFrac;
if (fadeFrac <= 0 || frac < fadeStart) {
this.setOp(cB, 1);
this.hideExcept(new Set([cB]));
this.emitShown(cB);
} else {
const blend = (frac - fadeStart) / fadeFrac;
this.setOp(cB, 1 - blend);
this.setOp(cN, blend);
this.hideExcept(new Set([cB, cN]));
this.emitShown(blend < 0.5 ? cB : cN);
}
} else if (currReady) {
// next frame still loading - hold the current frame at full opacity
this.setOp(curr, 1);
this.hideExcept(new Set([curr]));
this.emitShown(curr);
}
}
private markFirst(): void {
if (this.firstShown) return;
this.firstShown = true;
this.opt.onFirstFrame?.();
}
private emitShown(i: number): void {
if (i === this.lastShown) return;
this.lastShown = i;
this.opt.onShow?.(i);
}
private ensure(i: number): void {
if (i < 0 || i >= this.frameCount) return;
if (this.frames.has(i)) {
this.touch(i);
return;
}
if (!this.map.isStyleLoaded()) return;
const url = this.opt.urlForFrame(i);
const sources: string[] = [];
const layers: LayerInfo[] = [];
for (const ch of this.opt.channels) {
const sourceId = `omAnim_${ch.key}_${i}`;
const layerId = `omAnimL_${ch.key}_${i}`;
this.map.addSource(sourceId, ch.sourceSpec(url));
ch.addLayer(this.map, sourceId, layerId, this.opt.beforeLayer);
if (this.map.getLayer(layerId)) {
sources.push(sourceId);
layers.push({ layerId, opacityProp: ch.opacityProp, peak: ch.peakOpacity ?? this.target });
}
}
const entry: Entry = { sources, layers, loadedSources: new Set(), ready: false };
const allLoaded = () => entry.sources.every((s) => this.map.getSource(s)?.loaded());
const markReady = () => {
entry.ready = true;
if (entry.onData) {
this.map.off('sourcedata', entry.onData);
entry.onData = undefined;
}
this.opt.onReady?.(i);
if (this.preloading.has(i)) {
this.preloading.delete(i);
this.preloadInFlight = Math.max(0, this.preloadInFlight - 1);
queueMicrotask(() => this.pumpPreload());
}
};
if (allLoaded()) {
markReady();
} else {
const onData = (e: maplibregl.MapSourceDataEvent) => {
if (!e.sourceId || !entry.sources.includes(e.sourceId)) return;
if (!e.isSourceLoaded) return;
if (allLoaded()) markReady();
};
entry.onData = onData;
this.map.on('sourcedata', onData);
}
this.frames.set(i, entry);
this.touch(i);
this.evict();
}
private touch(i: number): void {
if (this.inLoop(i)) return; // loop frames are never evicted
const at = this.lru.indexOf(i);
if (at >= 0) this.lru.splice(at, 1);
this.lru.push(i);
}
private evict(): void {
const cap = this.opt.outOfRangePool ?? 6;
while (this.lru.length > cap) {
const i = this.lru.shift();
if (i === undefined || i === this.stillIndex) continue;
this.remove(i);
}
}
private remove(i: number): void {
const e = this.frames.get(i);
if (!e) return;
if (e.onData) this.map.off('sourcedata', e.onData);
for (const { layerId } of e.layers)
if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
for (const s of e.sources) if (this.map.getSource(s)) this.map.removeSource(s);
this.frames.delete(i);
this.visible.delete(i);
}
/** Set every channel of frame `i` to `mul` × its peak opacity. */
private setOp(i: number, mul: number): void {
const e = this.frames.get(i);
if (!e) return;
for (const { layerId, opacityProp, peak } of e.layers) {
if (this.map.getLayer(layerId)) this.map.setPaintProperty(layerId, opacityProp, peak * mul);
}
if (mul > 0.001) this.visible.add(i);
else this.visible.delete(i);
}
private hideExcept(keep: Set<number>): void {
for (const i of [...this.visible]) if (!keep.has(i)) this.setOp(i, 0);
}
/**
* Eagerly load `indices` with at most `maxInFlight` frames loading at once.
* Each frame is a set of layers, so MapLibre + the tile worker pool fetch and
* render them in parallel; the cap bounds the number of open tile requests.
*/
preload(indices: number[], maxInFlight = 8): void {
this.preloadMax = Math.max(1, maxInFlight);
this.preloadQueue = indices.slice();
this.pumpPreload();
}
private pumpPreload(): void {
while (this.preloadInFlight < this.preloadMax && this.preloadQueue.length) {
const i = this.preloadQueue.shift();
if (i === undefined || i < 0 || i >= this.frameCount) continue;
if (this.frames.get(i)?.ready || this.preloading.has(i)) continue;
this.preloading.add(i);
this.preloadInFlight++;
this.ensure(i); // synchronous cache hits settle via markReady immediately
}
}
/** Remove every layer/source (e.g. before a style reload). */
reset(): void {
this.stopRaf();
for (const i of [...this.frames.keys()]) this.remove(i);
this.lru = [];
this.visible.clear();
this.lastShown = -1;
this.firstShown = false;
this.preloadQueue = [];
this.preloading.clear();
this.preloadInFlight = 0;
}
destroy(): void {
this.alive = false;
this.reset();
}
}
+33
View File
@@ -0,0 +1,33 @@
import { writable } from 'svelte/store';
/**
* Cross-map sync state, shared by every WeatherMap on the page so the maps stay
* matched. Split into one store per concern so a change to one (e.g. speed)
* doesn't wake effects that only care about another (e.g. the playhead).
*/
/** Camera shared across maps; the last map the user moved writes it. */
export interface SharedCamera {
center: [number, number];
zoom: number;
}
export const sharedCamera = writable<SharedCamera | null>(null);
/** Whether playback is running. */
export const clockPlaying = writable(true);
/** Minimum wall-time per frame, in ms. */
export const clockSpeed = writable(450);
/** Loop / preload span in days from today 00:00 (Infinity = the whole run). */
export const clockSpan = writable(2.5);
/**
* The scrub target. `epoch` bumps on every scrub so followers re-apply even if
* the index repeats; `index` is -1 before the first scrub.
*/
export interface ScrubState {
index: number;
epoch: number;
}
export const clockScrub = writable<ScrubState>({ index: -1, epoch: 0 });
+166
View File
@@ -0,0 +1,166 @@
/**
* Thin integration layer over @openmeteo/weather-map-layer for the native
* animated weather maps. Responsibilities:
*
* - register the `om://` MapLibre protocol once (shared settings/cache)
* - resolve drizzli's selected model id to a maps domain that actually serves
* the requested variable, falling back to a global model when the model has
* no map domain (or its run lacks the variable) - "use the global models as
* fallback"
* - build the fully-resolved `.om` tile-source URLs for a given model run and
* forecast time (the timestamp lives in the path, the variable in the query)
*/
import {
type DomainMetaDataJson,
defaultOmProtocolSettings,
omProtocol
} from '@openmeteo/weather-map-layer';
import * as maplibregl from 'maplibre-gl';
import { modelDomainCandidates } from '$lib/utils/maps-domain';
/** Tile host. Always the production CDN (serves CORS/CORP for our headers). */
const BASE = 'https://map-tiles.open-meteo.com';
/**
* Global models (widest coverage) tried after the selected model's own
* candidates. dwd_icon leads: it is truly global, hourly, and carries the
* longest run, which makes for the smoothest animation.
*/
const GLOBAL_FALLBACKS = ['dwd_icon', 'ecmwf_ifs025', 'ncep_gfs013'];
// Register the protocol exactly once with a single shared settings object.
export const omSettings = { ...defaultOmProtocolSettings };
let registered = false;
export const registerOmProtocol = (): void => {
if (registered) return;
registered = true;
maplibregl.addProtocol('om', (params, abortController) =>
omProtocol(params, abortController, omSettings)
);
};
const pad = (n: number): string => String(n).padStart(2, '0');
/** Model-run directory, e.g. `2026/07/25/0600Z` (UTC). */
export const fmtModelRun = (run: Date): string =>
`${run.getUTCFullYear()}/${pad(run.getUTCMonth() + 1)}/${pad(run.getUTCDate())}/${pad(
run.getUTCHours()
)}${pad(run.getUTCMinutes())}Z`;
/** Forecast-time filename stem, e.g. `2026-07-25T1400` (UTC). */
export const fmtValidTime = (t: Date): string =>
`${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}T${pad(
t.getUTCHours()
)}${pad(t.getUTCMinutes())}`;
/** Plain https `.om` URL for a single forecast step. */
export const omFileUrl = (domain: string, run: Date, time: Date): string =>
`${BASE}/data_spatial/${domain}/${fmtModelRun(run)}/${fmtValidTime(time)}.om`;
/**
* `om://` source URL for one forecast step of a given variable. The same URL
* serves both a raster field and (with `arrows`) a vector `wind-arrows` layer;
* MapLibre asks the protocol for image vs vector tiles per source type.
*/
export const omSourceUrl = (
domain: string,
run: Date,
time: Date,
variable: string,
dark: boolean,
arrows = false
): string =>
`om://${omFileUrl(domain, run, time)}?variable=${variable}${arrows ? '&arrows=true' : ''}${
dark ? '&dark=true' : ''
}`;
/** Ordered, de-duplicated domain candidates for a model, then global fallbacks. */
const domainCandidates = (model: string): string[] => {
const seen = new Set<string>();
const out: string[] = [];
for (const d of [...(modelDomainCandidates[model] ?? [model]), ...GLOBAL_FALLBACKS]) {
if (!seen.has(d)) {
seen.add(d);
out.push(d);
}
}
return out;
};
const metaCache = new Map<string, Promise<DomainMetaDataJson | null>>();
const fetchLatestMeta = (
domain: string,
signal?: AbortSignal
): Promise<DomainMetaDataJson | null> => {
let pending = metaCache.get(domain);
if (!pending) {
pending = fetch(`${BASE}/data_spatial/${domain}/latest.json`, { signal })
.then((r) => (r.ok ? (r.json() as Promise<DomainMetaDataJson>) : null))
.catch(() => null);
metaCache.set(domain, pending);
// short TTL so a fresh model run is picked up without a reload
setTimeout(() => metaCache.delete(domain), 60_000);
}
return pending;
};
export interface ResolvedSource {
/** The maps domain we actually render (may be a global fallback). */
domain: string;
/** Model run of this domain's latest data. */
modelRun: Date;
/** Forecast steps as Dates, ascending. */
times: Date[];
/** True when we fell back off the user's selected model. */
fellBack: boolean;
}
export interface ResolveOptions {
signal?: AbortSignal;
/**
* Drop the first timestep. Accumulation variables (e.g. precipitation, an
* hourly sum) have no value at the analysis time; instantaneous variables
* (e.g. wind) do, so leave this off for them.
*/
skipFirst?: boolean;
}
/**
* Resolve the selected model to a renderable source for `variable`. Probes each
* candidate domain's `latest.json` in order and returns the first that serves
* the variable. Global fallbacks are appended so there is (almost) always a
* result.
*/
export const resolveSource = async (
model: string,
variable: string,
opts: ResolveOptions = {}
): Promise<ResolvedSource | null> => {
const { signal, skipFirst } = opts;
const candidates = domainCandidates(model);
const preferred = new Set(modelDomainCandidates[model] ?? [model]);
for (const domain of candidates) {
const meta = await fetchLatestMeta(domain, signal);
if (signal?.aborted) return null;
if (!meta || !meta.variables?.includes(variable) || !meta.valid_times?.length) continue;
const sorted = meta.valid_times
.map((s) => new Date(s))
.filter((d) => !isNaN(d.getTime()))
.sort((a, b) => a.getTime() - b.getTime());
const times = skipFirst && sorted.length > 1 ? sorted.slice(1) : sorted;
if (!times.length) continue;
return {
domain,
modelRun: new Date(meta.reference_time),
times,
fellBack: !preferred.has(domain)
};
}
return null;
};
+27 -24
View File
@@ -1,39 +1,36 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import * as m from '$lib/paraglide/messages';
import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte'; import UnlockDialog from './UnlockDialog.svelte';
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config'; import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
import { isSupporter, refreshSupporter, supporterState } from './supporter'; import { isPremium, premiumState, refreshPremium } from './premium';
interface Props { interface Props {
/** Short feature name shown in the locked panel headline. */ /** Short feature name shown in the locked panel headline. */
feature: string; feature?: string;
children: import('svelte').Snippet; children: import('svelte').Snippet;
} }
let { feature, children }: Props = $props(); let { feature = 'This page', children }: Props = $props();
let unlockOpen = $state(false); let unlockOpen = $state(false);
const price = getSupporterPrice(); const price = getPremiumPrice();
// Re-verify the stored key whenever the gate mounts. // Re-verify the stored key whenever the gate mounts.
onMount(refreshSupporter); onMount(refreshPremium);
// Show a brief spinner only when we have no cached answer yet and are checking. // Show a brief spinner only when we have no cached answer yet and are checking.
let initialChecking = $derived($supporterState.status === 'checking' && !$isSupporter); let initialChecking = $derived($premiumState.status === 'checking' && !$isPremium);
</script> </script>
{#if $isSupporter} {#if $isPremium}
{@render children()} {@render children()}
{:else if initialChecking} {:else if initialChecking}
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground"> <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"> <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" /> <path stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg> </svg>
{m.supporter_checking()} Checking your subscription…
</div> </div>
{:else} {:else}
<div <div
@@ -42,20 +39,26 @@
<div <div
class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary" 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" /> <!-- padlock -->
<svg
class="h-7 w-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<rect x="4" y="10" width="16" height="11" rx="2" />
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
</div> </div>
<!-- the feature name comes from a page subtitle ("météo historique"), which is <h2 class="text-xl font-bold tracking-tight">{feature} is a premium feature</h2>
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"> <p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
{m.supporter_gate_body({ price })} Support this open-source weather project and unlock the extras from {price}.
</p> </p>
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left"> <ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
{#each SUPPORTER_PERKS() as perk (perk)} {#each PREMIUM_PERKS as perk (perk)}
<li class="flex items-start gap-2.5 text-sm"> <li class="flex items-start gap-2.5 text-sm">
<svg <svg
class="mt-0.5 h-4 w-4 shrink-0 text-primary" class="mt-0.5 h-4 w-4 shrink-0 text-primary"
@@ -78,20 +81,20 @@
rel="noopener" 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" 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()} Subscribe
</a> </a>
<button <button
type="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" 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)} onclick={() => (unlockOpen = true)}
> >
{m.supporter_have_key()} I have a key
</button> </button>
</div> </div>
{#if $supporterState.status === 'invalid'} {#if $premiumState.status === 'invalid'}
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400"> <p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
{m.supporter_key_expired()} Your saved key is no longer valid or has expired.
</p> </p>
{/if} {/if}
</div> </div>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import { onMount } from 'svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { isPremium, refreshPremium } from './premium';
let open = $state(false);
// Verify once on load so the badge reflects real status site-wide.
onMount(refreshPremium);
</script>
<button
type="button"
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors {$isPremium
? '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={$isPremium ? 'Premium active' : 'Unlock premium'}
aria-label={$isPremium ? 'Premium active' : 'Unlock premium'}
>
{#if $isPremium}
<!-- star -->
<svg class="h-4.5 w-4.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
/>
</svg>
{:else}
<!-- padlock -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
aria-hidden="true"
>
<rect x="4" y="10" width="16" height="11" rx="2" />
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
{/if}
</button>
<UnlockDialog bind:open />
-29
View File
@@ -1,29 +0,0 @@
<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 './supporter';
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 />
-37
View File
@@ -1,37 +0,0 @@
<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 paywall. 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}
+20 -28
View File
@@ -6,16 +6,8 @@
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages'; import { SIGNUP_URL, getPremiumPrice } from './config';
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
import { SIGNUP_URL, getSupporterPrice } from './config';
import {
clearLicense,
isSupporter,
storedLicenseKey,
supporterState,
verifyKey
} from './supporter';
interface Props { interface Props {
open?: boolean; open?: boolean;
@@ -23,7 +15,7 @@
let { open = $bindable(false) }: Props = $props(); let { open = $bindable(false) }: Props = $props();
const price = getSupporterPrice(); const price = getPremiumPrice();
// Prefill with the stored key so an existing subscriber sees their key. // Prefill with the stored key so an existing subscriber sees their key.
let keyInput = $state(''); let keyInput = $state('');
@@ -48,7 +40,7 @@
// close shortly so the success state is visible for a beat // close shortly so the success state is visible for a beat
setTimeout(() => (open = false), 700); setTimeout(() => (open = false), 700);
} else { } else {
localError = result.error ?? m.supporter_key_invalid(); localError = result.error ?? 'That key is not valid or has expired.';
} }
} }
@@ -58,23 +50,23 @@
} }
let expiresLabel = $derived.by(() => { let expiresLabel = $derived.by(() => {
const exp = $supporterState.expires; const exp = $premiumState.expires;
if ($supporterState.status !== 'valid') return null; if ($premiumState.status !== 'valid') return null;
if (!exp) return m.supporter_lifetime(); if (!exp) return 'Lifetime access';
return m.supporter_active_until({ date: formatZoned(new Date(exp), 'UTC', 'd LLL yyyy') }); return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
}); });
</script> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-md"> <Dialog.Content class="sm:max-w-md">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{m.supporter_dialog_title()}</Dialog.Title> <Dialog.Title>Drizz.li Premium</Dialog.Title>
<Dialog.Description> <Dialog.Description>
{m.supporter_dialog_desc()} Paste the access key from your subscription email to unlock premium pages.
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
{#if $isSupporter && $supporterState.status === 'valid'} {#if $isPremium && $premiumState.status === 'valid'}
<div <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" 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"
> >
@@ -89,7 +81,7 @@
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
</svg> </svg>
<div> <div>
<p class="font-semibold">{m.supporter_extras_active()}</p> <p class="font-semibold">Premium is active</p>
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if} {#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
</div> </div>
</div> </div>
@@ -97,7 +89,7 @@
<form onsubmit={submit} class="grid gap-3"> <form onsubmit={submit} class="grid gap-3">
<div class="grid gap-1.5"> <div class="grid gap-1.5">
<Label for="license-key">{m.supporter_access_key()}</Label> <Label for="license-key">Access key</Label>
<Input <Input
id="license-key" id="license-key"
bind:value={keyInput} bind:value={keyInput}
@@ -110,36 +102,36 @@
{#if localError} {#if localError}
<p class="text-sm text-destructive">{localError}</p> <p class="text-sm text-destructive">{localError}</p>
{:else if $supporterState.status === 'error'} {:else if $premiumState.status === 'error'}
<p class="text-sm text-destructive"> <p class="text-sm text-destructive">
{m.supporter_server_unreachable()} Couldn't reach the server. Check your connection and try again.
</p> </p>
{/if} {/if}
<Button type="submit" disabled={submitting || !keyInput.trim()}> <Button type="submit" disabled={submitting || !keyInput.trim()}>
{#if submitting}{m.supporter_verifying()}{:else}{m.supporter_unlock_button()}{/if} {#if submitting}Verifying…{:else}Unlock{/if}
</Button> </Button>
</form> </form>
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch"> <Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
{#if $isSupporter} {#if $isPremium}
<button <button
type="button" type="button"
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={removeKey} onclick={removeKey}
> >
{m.supporter_remove_key()} Remove key from this device
</button> </button>
{:else} {:else}
<p class="text-center text-xs text-muted-foreground"> <p class="text-center text-xs text-muted-foreground">
{m.supporter_no_key_yet()} No key yet?
<a <a
href={SIGNUP_URL} href={SIGNUP_URL}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="font-semibold text-primary underline-offset-2 hover:underline" class="font-semibold text-primary underline-offset-2 hover:underline"
> >
{m.supporter_support_from({ price })} Subscribe from {price}
</a> </a>
</p> </p>
{/if} {/if}
+11 -19
View File
@@ -1,5 +1,3 @@
import * as m from '$lib/paraglide/messages';
/** /**
* Paywall configuration. * Paywall configuration.
* *
@@ -15,7 +13,7 @@ const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
/** Base URL of the self-hosted verify API (drizzli-paywall). */ /** Base URL of the self-hosted verify API (drizzli-paywall). */
export const PAYWALL_API_BASE = stripTrailingSlash( export const PAYWALL_API_BASE = stripTrailingSlash(
env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li' env.VITE_PAYWALL_API_BASE ?? 'https://paywall.drizz.li'
); );
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */ /** Where prospective subscribers go to sign up (the paywall repo's signup form). */
@@ -30,9 +28,9 @@ const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CH
const US_ZONES = const US_ZONES =
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/; /^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 type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
export function detectCurrency(): SupporterCurrency { export function detectCurrency(): PremiumCurrency {
if (typeof Intl === 'undefined') return 'EUR'; if (typeof Intl === 'undefined') return 'EUR';
let tz = ''; let tz = '';
try { try {
@@ -54,22 +52,16 @@ export function detectCurrency(): SupporterCurrency {
return 'EUR'; return 'EUR';
} }
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / Monat". */ /** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
export function getSupporterPrice(): string { export function getPremiumPrice(): string {
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
// deployment's .env keeps working.
const configured = env.VITE_SUPPORTER_PRICE ?? env.VITE_PREMIUM_PRICE;
if (configured) return configured;
const currency = detectCurrency(); const currency = detectCurrency();
const symbol = CURRENCY_SYMBOL[currency]; const symbol = CURRENCY_SYMBOL[currency];
const amount = currency === 'CHF' ? `${symbol} 3` : `${symbol}3`; return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
return m.supporter_price_per_month({ amount });
} }
/** Short, human list of what supporting unlocks (shown on the locked panel). /** Short, human list of what premium unlocks (shown on the locked panel). */
* A function, not a constant: the copy has to follow the active locale. */ export const PREMIUM_PERKS = [
export const SUPPORTER_PERKS = (): string[] => [ 'Historical weather & climate-normal comparisons',
m.supporter_perk_historical(), 'New premium features as they land'
m.supporter_perk_seasonal(),
m.supporter_perk_future()
]; ];
@@ -1,5 +1,5 @@
/** /**
* Supporter (subscription) state. * Premium (subscription) state.
* *
* The user pastes an access key once; it is stored locally and re-verified * 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 * against the self-hosted verify API on load. The last good result is cached so
@@ -14,14 +14,12 @@ import { derived, get, writable } from 'svelte/store';
import { persisted } from 'svelte-persisted-store'; import { persisted } from 'svelte-persisted-store';
import * as m from '$lib/paraglide/messages';
import { PAYWALL_API_BASE } from './config'; import { PAYWALL_API_BASE } from './config';
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */ /** The subscriber's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
export const storedLicenseKey = persisted<string>('license_key', ''); export const storedLicenseKey = persisted<string>('license_key', '');
export interface SupporterCache { export interface PremiumCache {
valid: boolean; valid: boolean;
tier?: string; tier?: string;
/** ISO date the subscription lapses, or null for a lifetime key. */ /** ISO date the subscription lapses, or null for a lifetime key. */
@@ -30,40 +28,20 @@ export interface SupporterCache {
checkedAt: number; 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. */ /** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
export const storedSupporterCache = persisted<SupporterCache | null>(CACHE_KEY, readLegacyCache()); export const storedPremiumCache = persisted<PremiumCache | null>('premium_cache_v1', null);
export type SupporterStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error'; export type PremiumStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
export interface SupporterState { export interface PremiumState {
status: SupporterStatus; status: PremiumStatus;
tier?: string; tier?: string;
expires?: string | null; expires?: string | null;
error?: string; error?: string;
} }
/** Live verification state for the current session. */ /** Live verification state for the current session. */
export const supporterState = writable<SupporterState>({ status: 'idle' }); export const premiumState = writable<PremiumState>({ status: 'idle' });
function notExpired(expires: string | null | undefined): boolean { function notExpired(expires: string | null | undefined): boolean {
if (!expires) return true; // lifetime key if (!expires) return true; // lifetime key
@@ -72,12 +50,12 @@ function notExpired(expires: string | null | undefined): boolean {
} }
/** /**
* Whether supporter content should be shown. A live "valid"/"invalid" result * Whether premium content should be shown. A live "valid"/"invalid" result wins;
* wins; otherwise we fall back to the cached result (so a reload or a brief * otherwise we fall back to the cached result (so a reload or a brief network
* network blip doesn't lock a paying user out). * blip doesn't lock a paying user out).
*/ */
export const isSupporter = derived( export const isPremium = derived(
[supporterState, storedSupporterCache], [premiumState, storedPremiumCache],
([$state, $cache]): boolean => { ([$state, $cache]): boolean => {
if ($state.status === 'valid') return true; if ($state.status === 'valid') return true;
if ($state.status === 'invalid') return false; if ($state.status === 'invalid') return false;
@@ -100,11 +78,11 @@ export interface VerifyResult {
export async function verifyKey(key: string): Promise<VerifyResult> { export async function verifyKey(key: string): Promise<VerifyResult> {
const trimmed = key.trim(); const trimmed = key.trim();
if (!trimmed) { if (!trimmed) {
supporterState.set({ status: 'invalid' }); premiumState.set({ status: 'invalid' });
return { valid: false, error: m.supporter_enter_key() }; return { valid: false, error: 'Enter your access key.' };
} }
supporterState.set({ status: 'checking' }); premiumState.set({ status: 'checking' });
try { try {
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, { const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
headers: { accept: 'application/json' } headers: { accept: 'application/json' }
@@ -113,39 +91,39 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
if (res.ok && data.valid) { if (res.ok && data.valid) {
storedLicenseKey.set(trimmed); storedLicenseKey.set(trimmed);
storedSupporterCache.set({ storedPremiumCache.set({
valid: true, valid: true,
tier: data.tier, tier: data.tier,
expires: data.expires ?? null, expires: data.expires ?? null,
checkedAt: Date.now() checkedAt: Date.now()
}); });
supporterState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null }); premiumState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
return { valid: true, tier: data.tier, expires: data.expires ?? null }; return { valid: true, tier: data.tier, expires: data.expires ?? null };
} }
storedSupporterCache.set({ valid: false, checkedAt: Date.now() }); storedPremiumCache.set({ valid: false, checkedAt: Date.now() });
supporterState.set({ status: 'invalid' }); premiumState.set({ status: 'invalid' });
return { valid: false }; return { valid: false };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
supporterState.set({ status: 'error', error: message }); premiumState.set({ status: 'error', error: message });
return { valid: false, error: message }; return { valid: false, error: message };
} }
} }
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */ /** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
export async function refreshSupporter(): Promise<void> { export async function refreshPremium(): Promise<void> {
const key = get(storedLicenseKey); const key = get(storedLicenseKey);
if (!key) { if (!key) {
supporterState.set({ status: 'idle' }); premiumState.set({ status: 'idle' });
return; return;
} }
await verifyKey(key); await verifyKey(key);
} }
/** Forget the key and supporter state ("sign out"). */ /** Forget the key and premium state ("sign out"). */
export function clearLicense(): void { export function clearLicense(): void {
storedLicenseKey.set(''); storedLicenseKey.set('');
storedSupporterCache.set(null); storedPremiumCache.set(null);
supporterState.set({ status: 'idle' }); premiumState.set({ status: 'idle' });
} }
-156
View File
@@ -1,156 +0,0 @@
/**
* 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);
}
+136 -400
View File
@@ -10,12 +10,10 @@
* - Efficient binary protobuf transport instead of JSON * - Efficient binary protobuf transport instead of JSON
* - Consistent timestamp and unit handling * - Consistent timestamp and unit handling
*/ */
import { Model } from '@openmeteo/sdk/model';
import { Unit } from '@openmeteo/sdk/unit'; import { Unit } from '@openmeteo/sdk/unit';
import { fetchWeatherApi } from 'openmeteo'; import { fetchWeatherApi } from 'openmeteo';
import { type DaylightBand, buildDaylightBands } from '$lib/charts/bands'; 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 { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time'; import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
@@ -25,7 +23,6 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast'; const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble'; const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive'; const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
const SEASONAL_URL = 'https://seasonal-api.open-meteo.com/v1/seasonal';
// ─── Core Helpers ─────────────────────────────────────────────────────────────── // ─── Core Helpers ───────────────────────────────────────────────────────────────
@@ -210,17 +207,6 @@ export interface WeekDailyData {
windspeed_10m_max: number[]; windspeed_10m_max: number[];
windgusts_10m_max: number[]; windgusts_10m_max: number[];
winddirection_10m_dominant: 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 { export interface WeekForecastResult {
@@ -242,10 +228,7 @@ export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams {
} }
export interface ModelSeriesData { export interface ModelSeriesData {
/** Stable identifier requested by the UI. */ modelName: string;
modelId: string;
/** Concrete model identifier reported by the API (useful for seamless/best-match requests). */
resolvedModelId: string;
variables: Record<string, number[]>; variables: Record<string, number[]>;
} }
@@ -290,65 +273,6 @@ export interface EnsembleForecastResult {
hourlyUnitsFlat: Record<string, string>; 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 ──────────────────────────────────────────────────────── // ─── Week Forecast Fetch ────────────────────────────────────────────────────────
// Fallback set when the caller does not specify which hourly variables it // Fallback set when the caller does not specify which hourly variables it
@@ -377,13 +301,7 @@ const WEEK_DAILY_VARS = [
'precipitation_sum', 'precipitation_sum',
'wind_speed_10m_max', 'wind_speed_10m_max',
'wind_gusts_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; ] as const;
/** /**
@@ -485,17 +403,6 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
const sunriseVar = dailyBlock.variables(3)!; const sunriseVar = dailyBlock.variables(3)!;
const sunsetVar = dailyBlock.variables(4)!; 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 = { const daily: WeekDailyData = {
weather_code: getValues(dailyBlock.variables(0)!), weather_code: getValues(dailyBlock.variables(0)!),
temperature_2m_max: getValues(dailyBlock.variables(1)!), temperature_2m_max: getValues(dailyBlock.variables(1)!),
@@ -506,13 +413,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
precipitation_sum: getValues(dailyBlock.variables(6)!), precipitation_sum: getValues(dailyBlock.variables(6)!),
windspeed_10m_max: getValues(dailyBlock.variables(7)!), windspeed_10m_max: getValues(dailyBlock.variables(7)!),
windgusts_10m_max: getValues(dailyBlock.variables(8)!), 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); const daylightBands = buildDaylightBands(daily.sunrise, daily.sunset);
@@ -539,8 +440,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
* compatible with existing chart utilities. * compatible with existing chart utilities.
*/ */
export async function fetchModelComparison( export async function fetchModelComparison(
params: ModelCompareParams, params: ModelCompareParams
options: { signal?: AbortSignal } = {}
): Promise<ModelCompareResult> { ): Promise<ModelCompareResult> {
const forecastApiParams: Record<string, string | number | undefined> = { const forecastApiParams: Record<string, string | number | undefined> = {
latitude: String(params.latitude), latitude: String(params.latitude),
@@ -554,28 +454,14 @@ export async function fetchModelComparison(
timezone: params.timezone timezone: params.timezone
}; };
const responses = await fetchWeatherApi( const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
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 // With multiple models, we get one response per model
const firstResponse = responses[0]; const firstResponse = responses[0];
const utcOffsetSeconds = firstResponse.utcOffsetSeconds(); const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
const timezone = firstResponse.timezone() ?? params.timezone ?? 'UTC'; 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); const timestamps = getTimestamps(hourlyBlock);
// Extract sunrise/sunset from the first response's daily block // Extract sunrise/sunset from the first response's daily block
@@ -605,22 +491,16 @@ export async function fetchModelComparison(
); );
hourlyFlat['time'] = timeInUnixSeconds; hourlyFlat['time'] = timeInUnixSeconds;
for (const [responseIndex, response] of responses.entries()) { for (const response of responses) {
const modelHourly = response.hourly(); const modelHourly = response.hourly();
if (!modelHourly) continue; if (!modelHourly) continue;
// The multi-model endpoint preserves request order and returns one response // Determine model name from the 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 modelEnum = response.model();
const resolvedModelId = Model[modelEnum] ?? `model_${modelEnum}`; const modelName = modelEnumToString(modelEnum);
const modelId = params.models[responseIndex] ?? resolvedModelId;
const modelData: ModelSeriesData = { const modelData: ModelSeriesData = {
modelId, modelName,
resolvedModelId,
variables: {} variables: {}
}; };
@@ -633,7 +513,7 @@ export async function fetchModelComparison(
modelData.variables[varName] = values; modelData.variables[varName] = values;
// Build flat key like "temperature_2m_icon_seamless" // Build flat key like "temperature_2m_icon_seamless"
const flatKey = `${varName}_${modelId}`; const flatKey = `${varName}_${modelName}`;
hourlyFlat[flatKey] = values; hourlyFlat[flatKey] = values;
// Record unit // Record unit
@@ -811,6 +691,130 @@ export async function fetchEnsembleForecast(
}; };
} }
// ─── Model Enum Mapping ─────────────────────────────────────────────────────────
/**
* Maps the SDK Model enum integer to a string model name.
* This table must stay in sync with the @openmeteo/sdk Model enum.
*/
function modelEnumToString(modelEnum: number): string {
const modelMap: Record<number, string> = {
0: 'undefined',
1: 'best_match',
2: 'gfs_seamless',
3: 'gfs_global',
4: 'gfs_hrrr',
5: 'meteofrance_seamless',
6: 'meteofrance_arpege_seamless',
7: 'meteofrance_arpege_world',
8: 'meteofrance_arpege_europe',
9: 'meteofrance_arome_seamless',
10: 'meteofrance_arome_france',
11: 'meteofrance_arome_france_hd',
12: 'jma_seamless',
13: 'jma_msm',
14: 'jms_gsm',
15: 'jma_gsm',
16: 'gem_seamless',
17: 'gem_global',
18: 'gem_regional',
19: 'gem_hrdps_continental',
20: 'icon_seamless',
21: 'icon_global',
22: 'icon_eu',
23: 'icon_d2',
24: 'ecmwf_ifs04',
25: 'metno_nordic',
26: 'era5_seamless',
27: 'era5',
28: 'cerra',
29: 'era5_land',
30: 'ecmwf_ifs',
31: 'gwam',
32: 'ewam',
33: 'glofas_seamless_v3',
34: 'glofas_forecast_v3',
35: 'glofas_consolidated_v3',
36: 'glofas_seamless_v4',
37: 'glofas_forecast_v4',
38: 'glofas_consolidated_v4',
39: 'gfs025',
40: 'gfs05',
41: 'CMCC_CM2_VHR4',
42: 'FGOALS_f3_H_highresSST',
43: 'FGOALS_f3_H',
44: 'HiRAM_SIT_HR',
45: 'MRI_AGCM3_2_S',
46: 'EC_Earth3P_HR',
47: 'MPI_ESM1_2_XR',
48: 'NICAM16_8S',
49: 'cams_europe',
50: 'cams_global',
51: 'cfsv2',
52: 'era5_ocean',
53: 'cma_grapes_global',
54: 'bom_access_global',
55: 'bom_access_global_ensemble',
56: 'arpae_cosmo_seamless',
57: 'arpae_cosmo_2i',
58: 'arpae_cosmo_2i_ruc',
59: 'arpae_cosmo_5m',
60: 'ecmwf_ifs025',
61: 'ecmwf_aifs025',
62: 'gfs013',
63: 'gfs_graphcast025',
64: 'ecmwf_wam025',
65: 'meteofrance_wave',
66: 'meteofrance_currents',
67: 'ecmwf_wam025_ensemble',
68: 'ncep_gfswave025',
69: 'ncep_gefswave025',
70: 'knmi_seamless',
71: 'knmi_harmonie_arome_europe',
72: 'knmi_harmonie_arome_netherlands',
73: 'dmi_seamless',
74: 'dmi_harmonie_arome_europe',
75: 'metno_seamless',
76: 'era5_ensemble',
77: 'ecmwf_ifs_analysis',
78: 'ecmwf_ifs_long_window',
79: 'ecmwf_ifs_analysis_long_window',
80: 'ukmo_global_deterministic_10km',
81: 'ukmo_uk_deterministic_2km',
82: 'ukmo_seamless',
83: 'ncep_gfswave016',
84: 'ncep_nbm_conus',
85: 'ukmo_global_ensemble_20km',
86: 'ecmwf_aifs025_single',
87: 'jma_jaxa_himawari',
88: 'eumetsat_sarah3',
89: 'eumetsat_lsa_saf_msg',
90: 'eumetsat_lsa_saf_iodc',
91: 'satellite_radiation_seamless',
92: 'kma_gdps',
93: 'kma_ldps',
94: 'kma_seamless',
95: 'italia_meteo_arpae_icon_2i',
96: 'ukmo_uk_ensemble_2km',
97: 'meteofrance_arome_france_hd_15min',
98: 'meteofrance_arome_france_15min',
99: 'meteoswiss_icon_ch1',
100: 'meteoswiss_icon_ch2',
101: 'meteoswiss_icon_ch1_ensemble',
102: 'meteoswiss_icon_ch2_ensemble',
103: 'meteoswiss_icon_seamless',
104: 'ncep_nam_conus',
105: 'icon_d2_ruc',
106: 'ecmwf_seas5',
107: 'ecmwf_ec46',
108: 'ecmwf_seasonal_seamless',
109: 'ecmwf_ifs_seamless',
110: 'jma_jaxa_mtg_fci',
111: 'gem_hrdps_west'
};
return modelMap[modelEnum] ?? `model_${modelEnum}`;
}
// ─── Historical (Archive) Types ───────────────────────────────────────────── // ─── Historical (Archive) Types ─────────────────────────────────────────────
export interface HistoricalDailyData { export interface HistoricalDailyData {
@@ -838,8 +842,6 @@ export interface HistoricalForecastParams extends WeatherLocation, WeatherUnitPa
end_date: string; end_date: string;
/** Hourly API variables to request; defaults to the core week set. */ /** Hourly API variables to request; defaults to the core week set. */
hourlyVariables?: string[]; hourlyVariables?: string[];
/** Reanalysis to read from; omitted lets the API pick. */
model?: string;
} }
export interface HistoricalForecastResult { export interface HistoricalForecastResult {
@@ -899,8 +901,7 @@ export async function fetchHistoricalWeather(
temperature_unit: params.temperature_unit ?? 'celsius', temperature_unit: params.temperature_unit ?? 'celsius',
wind_speed_unit: params.wind_speed_unit ?? 'kmh', wind_speed_unit: params.wind_speed_unit ?? 'kmh',
precipitation_unit: params.precipitation_unit ?? 'mm', precipitation_unit: params.precipitation_unit ?? 'mm',
timezone: params.timezone, timezone: params.timezone
models: params.model && params.model !== 'best_match' ? params.model : undefined
}; };
const cleanParams: Record<string, string> = {}; const cleanParams: Record<string, string> = {};
@@ -1083,268 +1084,3 @@ export async function fetchClimateNormals(params: ClimateNormalsParams): Promise
precipitation_unit: params.precipitation_unit ?? 'mm' 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 };
});
}
-33
View File
@@ -1,33 +0,0 @@
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);
/**
* 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);
}
/**
* 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);
});
}
+4 -45
View File
@@ -86,37 +86,6 @@ export const defaultVariablePrefs: VariablePrefs = {
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs); 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. */ /** Hourly table interval: 3-hourly (default) or 1-hourly. */
export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3); export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3);
@@ -138,33 +107,23 @@ export const defaultChartLayout: ChartPanel[] = [
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout); 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. */ /** Selected ensemble model for the 14-day spread forecast. */
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless'); 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. */ /** Measurement units, shared across every forecast page and persisted. */
export interface UnitPrefs { export interface UnitPrefs {
temperature_unit: 'celsius' | 'fahrenheit'; temperature_unit: 'celsius' | 'fahrenheit';
wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn'; wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn';
precipitation_unit: 'mm' | 'inch'; precipitation_unit: 'mm' | 'inch';
/** Clock format for displayed times: 24-hour or 12-hour AM/PM. */
time_format: '24h' | '12h';
} }
export const defaultUnits: UnitPrefs = { export const defaultUnits: UnitPrefs = {
temperature_unit: 'celsius', temperature_unit: 'celsius',
wind_speed_unit: 'kmh', wind_speed_unit: 'kmh',
precipitation_unit: 'mm' precipitation_unit: 'mm',
time_format: '24h'
}; };
export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits); export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits);
+5 -17
View File
@@ -1,24 +1,12 @@
import { isSameDay as isSameDayDateFns } from 'date-fns'; import { isSameDay as isSameDayDateFns } from 'date-fns';
import { formatInTimeZone, toZonedTime } from 'date-fns-tz'; 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 };
/** /**
* Formats a UTC Date into a string for a specific timezone using date-fns patterns. * Formats a UTC Date into a string for a specific timezone using date-fns patterns.
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc. * Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
*/ */
export function formatZoned(date: Date, timeZone: string, pattern: string): string { export function formatZoned(date: Date, timeZone: string, pattern: string): string {
return formatInTimeZone(date, timeZone, pattern, { return formatInTimeZone(date, timeZone, pattern);
locale: DATE_LOCALES[getLocale()] ?? enGB
});
} }
/** /**
@@ -47,17 +35,17 @@ export function getRelativeDayLabel(date: Date, timeZone: string): string {
const zonedDate = toZonedTime(date, timeZone); const zonedDate = toZonedTime(date, timeZone);
const zonedNow = toZonedTime(now, timeZone); const zonedNow = toZonedTime(now, timeZone);
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today(); if (isSameDayDateFns(zonedDate, zonedNow)) return 'Today';
const tomorrow = new Date(zonedNow); const tomorrow = new Date(zonedNow);
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
if (isSameDayDateFns(zonedDate, tomorrow)) return m.day_tomorrow(); if (isSameDayDateFns(zonedDate, tomorrow)) return 'Tomorrow';
const yesterday = new Date(zonedNow); const yesterday = new Date(zonedNow);
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday(); if (isSameDayDateFns(zonedDate, yesterday)) return 'Yesterday';
return formatZoned(date, timeZone, 'EEE d MMM'); return formatInTimeZone(date, timeZone, 'EEE d MMM');
} }
/** /**
-62
View File
@@ -1,62 +0,0 @@
import { tick } from 'svelte';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* 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.
*/
export async function runDayTransition(update: () => void): Promise<void> {
if (prefersReducedMotion() || !document.startViewTransition) {
update();
return;
}
const root = document.documentElement;
root.classList.add('day-switch');
try {
// Svelte applies the change on the next tick; the transition has to wait
// for that before it snapshots the new state.
await document.startViewTransition(async () => {
update();
await tick();
}).finished;
} catch {
/* a superseded transition is fine - the DOM is already up to date */
} finally {
root.classList.remove('day-switch');
}
}
/**
* 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 (typeof document.startViewTransition === 'function' || 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();
}
};
}
+4 -15
View File
@@ -1,7 +1,5 @@
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
import { deLocalizeHref, localizeHref } from '$lib/paraglide/runtime';
import type { GeoLocation } from '$lib/stores/settings'; import type { GeoLocation } from '$lib/stores/settings';
export const geoLocationNameToRoute = (name: string) => { export const geoLocationNameToRoute = (name: string) => {
@@ -13,14 +11,7 @@ export const geoLocationNameToRoute = (name: string) => {
// selections navigate here directly, no geocoding id involved // selections navigate here directly, no geocoding id involved
const COORD_ROUTE = /^(-?\d+(?:\.\d+)?)N(-?\d+(?:\.\d+)?)E$/i; const COORD_ROUTE = /^(-?\d+(?:\.\d+)?)N(-?\d+(?:\.\d+)?)E$/i;
/** Only the fields the route is built from, so callers holding a partial record export function buildLocationRoute(location: GeoLocation): string {
* (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 // coordinate-only locations (GPS) have no real geocoding id
if (location.feature_code === 'COORD' || !location.id) { if (location.feature_code === 'COORD' || !location.id) {
return `${location.latitude.toFixed(4)}N${location.longitude.toFixed(4)}E`; return `${location.latitude.toFixed(4)}N${location.longitude.toFixed(4)}E`;
@@ -126,12 +117,10 @@ export async function resolveLocationFromRoute({
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves // trailingSlash is 'always' (see routes/+layout.ts), so the router serves
// every path with a trailing slash. Match that here or the equality check // every path with a trailing slash. Match that here or the equality check
// never holds and the redirect loops forever. The comparison also has to // never holds and the redirect loops forever.
// 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)}/`; const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
if (deLocalizeHref(event.url.pathname) !== canonicalPath) { if (event.url.pathname !== canonicalPath) {
throw redirect(303, localizeHref(canonicalPath)); throw redirect(303, canonicalPath);
} }
return location; return location;
+5 -4
View File
@@ -9,10 +9,10 @@
* automatically once the maps app ships seamless support; until then they fall * automatically once the maps app ships seamless support; until then they fall
* back to their widest-coverage member (a regional member could be entirely * 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 * off-screen). Models without an entry are tried under their own id; models
* the map does not serve at all (best_match, google, UKMO ensembles) * the map does not serve at all (best_match, bom, google, UKMO ensembles)
* resolve to null. * resolve to null.
*/ */
const modelDomainCandidates: Record<string, string[]> = { export const modelDomainCandidates: Record<string, string[]> = {
// DWD Germany // DWD Germany
icon_seamless: ['dwd_icon_seamless', 'dwd_icon'], icon_seamless: ['dwd_icon_seamless', 'dwd_icon'],
icon_global: ['dwd_icon'], icon_global: ['dwd_icon'],
@@ -46,8 +46,9 @@ const modelDomainCandidates: Record<string, string[]> = {
// MeteoSwiss (CH2 covers a wider area than CH1) // MeteoSwiss (CH2 covers a wider area than CH1)
meteoswiss_icon_seamless: ['meteoswiss_icon_ch2'], meteoswiss_icon_seamless: ['meteoswiss_icon_ch2'],
// CHMI Czech Republic (Central Europe is the widest native domain) // KMA Korea (kma_ldps is not served by the maps project)
chmi_aladin_seamless: ['chmi_aladin_seamless', 'chmi_aladin_central_europe_2km'], kma_seamless: ['kma_gdps'],
kma_ldps: ['kma_gdps'],
// JMA Japan // JMA Japan
jma_seamless: ['jma_seamless', 'jma_gsm'], jma_seamless: ['jma_seamless', 'jma_gsm'],
-56
View File
@@ -1,56 +0,0 @@
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();
}
};
}
-51
View File
@@ -1,51 +0,0 @@
/**
* 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".
*
* Callers must pass the URL *untracked* (`get(page).url`, not `$page.url`).
* Reading it reactively inside the same effect that writes it creates a loop:
* replaceState publishes a new URL, the effect re-runs, writes again - which
* Svelte eventually kills with `effect_update_depth_exceeded`, hanging the page.
*/
import { browser } from '$app/environment';
import { replaceState } from '$app/navigation';
export function syncSearchParams(url: URL, updates: Record<string, string | null>): void {
if (!browser) return;
const next = new URL(url);
for (const [key, value] of Object.entries(updates)) {
if (value == null || value === '') next.searchParams.delete(key);
else next.searchParams.set(key, value);
}
if (next.href === url.href) return;
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 */
}
});
}
}
/** 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;
}
+6 -145
View File
@@ -1,158 +1,35 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { get } from 'svelte/store';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
import { storedTheme } from '$lib/stores/settings'; import { storedTheme } from '$lib/stores/settings';
import Footer from '$lib/components/navigation/footer.svelte';
import Header from '$lib/components/navigation/header.svelte'; import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte'; import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { routePath } from '$lib/i18n';
import './layout.css'; import './layout.css';
let { children } = $props(); let { children } = $props();
// keep the .dark class in sync with the persisted theme; in 'system' mode // keep the .dark class in sync with the persisted theme; in 'system' mode
// follow the OS preference live // follow the OS preference live
let themeSettled = false;
let themeTimer = 0;
$effect(() => { $effect(() => {
const theme = $storedTheme; const theme = $storedTheme;
const mq = window.matchMedia('(prefers-color-scheme: dark)'); const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => { const apply = () => {
const root = document.documentElement;
const dark = theme === 'dark' || (theme === 'system' && mq.matches); const dark = theme === 'dark' || (theme === 'system' && mq.matches);
const paint = () => root.classList.toggle('dark', dark); document.documentElement.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 (document.startViewTransition) {
// one cross-fade of the whole document; component transitions untouched
document.startViewTransition(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(); apply();
mq.addEventListener('change', apply); mq.addEventListener('change', apply);
return () => mq.removeEventListener('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
// deliberately held open until the new page reports that its data has landed.
// The browser keeps showing the old snapshot for that whole time (rather than
// flashing a skeleton), then fades it into the finished page.
//
// The wait is capped: past the ceiling we cross-fade into whatever is on
// screen instead of freezing the UI on a slow network.
const READY_CEILING_MS = 2200;
const FADE_OUT_MS = 170;
let contentVisible = $state(true);
let revealTimer = 0;
const reducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Routes that fetch their own forecast after mounting. Knowing this up front
// is what makes the wait reliable: the layout clears the ready flag before
// the swap rather than trusting the incoming page to have done it.
const DATA_ROUTES = new Set([
'/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 at the ceiling. */
async function waitForContent(): Promise<void> {
const deadline = Date.now() + READY_CEILING_MS;
while (!get(pageContentReady) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 40));
}
// one more frame so the page paints its data before the snapshot is taken
await tick();
}
onNavigate((navigation) => {
if (reducedMotion()) return;
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
if (loadsData) markPageLoading();
if (typeof document.startViewTransition === 'function') {
return new Promise<void>((swap) => {
document.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 (loadsData) await waitForContent();
});
});
}
// No view transitions: fall back to fading out, then back in on arrival.
clearTimeout(revealTimer);
contentVisible = false;
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
});
let mainEl = $state<HTMLElement | null>(null);
afterNavigate((navigation) => {
// The page scrolls inside <main>, not the window, so SvelteKit's own scroll
// handling never touches it and a new page would open half way down.
// Back/forward and in-page anchors keep their position.
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
mainEl?.scrollTo({ top: 0 });
}
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
contentVisible = true;
return;
}
const startedAt = Date.now();
const reveal = () => {
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
contentVisible = true;
return;
}
revealTimer = window.setTimeout(reveal, 60);
};
reveal();
});
// the maps page embeds a full-bleed map: no padding, no scrolling // the maps page embeds a full-bleed map: no padding, no scrolling
let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps')); let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
let sidebarCollapsed = $state(false); let sidebarCollapsed = $state(false);
let mobileMenuOpen = $state(false); let mobileMenuOpen = $state(false);
@@ -205,33 +82,17 @@
<div class="flex min-w-0 flex-1 flex-col h-full"> <div class="flex min-w-0 flex-1 flex-col h-full">
<Header onMenuToggle={toggleMobileMenu} /> <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 <main
bind:this={mainEl} class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 lg:px-8 lg:py-6'}
class={fullBleed
? 'flex-1 overflow-hidden'
: 'flex flex-1 flex-col overflow-y-auto p-3 lg:px-8 lg:py-6'}
> >
{#if fullBleed} {#if fullBleed}
{@render children()} {@render children()}
{:else} {:else}
<!-- cap the content width on very large screens; the footer below <!-- cap the content width on very large screens; generous bottom room
gives the page its ending, so only modest bottom room is needed --> so the last chart/table never sits flush against the viewport edge -->
<div <div class="mx-auto w-full max-w-[1536px] pb-80">
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
class:page-fade-hidden={!contentVisible}
>
{@render children()} {@render children()}
</div> </div>
<!-- full-bleed footer inside the scroll area (its own inner max-w),
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} {/if}
</main> </main>
</div> </div>
+3 -16
View File
@@ -1,16 +1,3 @@
<script lang="ts"> <svelte:head>
import { onMount } from 'svelte'; <title>Drizzli</title>
</svelte:head>
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
@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async () => {
throw redirect(303, '/weather/week/');
}) satisfies PageLoad;
-11
View File
@@ -1,11 +0,0 @@
<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 }} />
-46
View File
@@ -1,46 +0,0 @@
<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 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
@@ -1,43 +0,0 @@
<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, 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>
-46
View File
@@ -1,46 +0,0 @@
<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 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>
-48
View File
@@ -1,48 +0,0 @@
<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, 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>
-47
View File
@@ -1,47 +0,0 @@
<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 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>
-114
View File
@@ -132,118 +132,4 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
page reports its data has arrived. */
.page-fade {
transition: opacity 320ms ease;
}
.page-fade-hidden {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-fade {
transition: none;
}
.page-fade-hidden {
opacity: 1;
}
}
/* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part
in the cross-fade. Everything else keeps its pixels: the `day-switch`
class cancels the root animation, so the strip, header and page chrome do
not so much as flicker while the table, summary and charts swap over. */
: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;
}
/* The sticky day strip never changes during a day switch, but it has to be
captured too: view-transition snapshots all paint in one layer above the
page, so without its own group (and a higher z-index in that layer) the
fading table and charts would slide over the top of it. */
:root.day-switch .daystrip {
view-transition-name: daystrip;
}
::view-transition-group(daystrip) {
z-index: 20;
}
/* Same for the topbar: a captured region's snapshot is painted at its layout
position, including the part normally scrolled up behind the chrome. */
:root.day-switch .topbar {
view-transition-name: topbar;
}
::view-transition-group(topbar) {
z-index: 30;
}
::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;
}
:root.day-switch::view-transition-old(root),
:root.day-switch::view-transition-new(root) {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.day-region-table,
.day-region-summary,
.day-region-charts,
.daystrip,
.topbar {
view-transition-name: none;
}
}
/* Theme changes cross-fade the whole document in one pass (see
routes/+layout.svelte). Doing it as a view transition instead of a blanket
`* { transition }` matters: that blanket rule also stretched every
component's own hover and focus transitions to 400ms for the duration of
the switch, which read as lag on interactive controls. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 400ms;
animation-timing-function: ease;
}
/* 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
@@ -1,11 +0,0 @@
<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 }} />
@@ -1,32 +0,0 @@
<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>
@@ -1,30 +0,0 @@
<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>
@@ -1,33 +0,0 @@
<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>
@@ -1,33 +0,0 @@
<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>
@@ -1,33 +0,0 @@
<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
@@ -1,11 +0,0 @@
<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
@@ -1,150 +0,0 @@
<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
@@ -1,145 +0,0 @@
<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
@@ -1,149 +0,0 @@
<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
@@ -1,151 +0,0 @@
<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
@@ -1,149 +0,0 @@
<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
@@ -1,11 +0,0 @@
<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 }} />
-87
View File
@@ -1,87 +0,0 @@
<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 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
@@ -1,83 +0,0 @@
<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, 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>
-91
View File
@@ -1,91 +0,0 @@
<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, 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
@@ -1,89 +0,0 @@
<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, 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>
-89
View File
@@ -1,89 +0,0 @@
<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, 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 -64
View File
@@ -1,72 +1,9 @@
<script lang="ts"> <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 { interface Props {
children?: Snippet; children?: import('svelte').Snippet;
} }
let { children }: Props = $props(); 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
);
</script> </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}
</h1>
<p class="truncate text-sm text-muted-foreground">
<span class="lg:hidden"
>{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>{subtitle}
</p>
</div>
</div>
{#if actions}{@render actions()}{/if}
</div>
{/if}
{@render children?.()} {@render children?.()}
-13
View File
@@ -1,13 +0,0 @@
<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
@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async () => {
throw redirect(303, '/weather/week/');
}) satisfies PageLoad;
+2 -3
View File
@@ -3,19 +3,18 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect // at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of // target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender // being baked to the default city during prerender
onMount(() => { onMount(() => {
goto( goto(
href('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }), resolve('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{ {
replaceState: true replaceState: true
} }
@@ -2,26 +2,19 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { page } from '$app/stores';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings'; import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts'; import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { import {
type DaylightBand, type DaylightBand,
type EnsembleForecastResult, type EnsembleForecastResult,
fetchEnsembleForecast fetchEnsembleForecast
} from '$lib/services/weather'; } from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, ensembleModelGroups } from '../../options'; import { defaultParameters, ensembleModelGroups } from '../../options';
import ModelSelector from '../../week/[location]/ModelSelector.svelte'; import ModelSelector from '../../week/[location]/ModelSelector.svelte';
@@ -43,11 +36,6 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null);
useHeroActions(heroActions);
// the URL is the source of truth: location comes from the load function, // the URL is the source of truth: location comes from the load function,
// which is also correct on hydrated prerendered pages. The persisted store // which is also correct on hydrated prerendered pages. The persisted store
// only mirrors it so the header and bare /weather/* redirects follow along. // only mirrors it so the header and bare /weather/* redirects follow along.
@@ -84,19 +72,11 @@
// ─── Lifecycle ────────────────────────────────────────────────────────────── // ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => { onMount(() => {
// a shared link carries its model; otherwise fall back to the stored choice // preselect the persisted ensemble model (client-only, keeps SSR stable)
const fromUrl = get(page).url.searchParams.get('model'); params.models = [get(storedEnsembleModel)];
params.models = [fromUrl || get(storedEnsembleModel)];
mounted = true; mounted = true;
}); });
// keep the plotted ensemble in the URL
$effect(() => {
const model = params.models?.[0];
if (!mounted || !model) return;
syncSearchParams(get(page).url, { model });
});
// components persist across refetches; entries are null while unmounted // components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null)); let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
@@ -292,20 +272,40 @@
}); });
</script> </script>
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) --> <!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
{#snippet heroActions()}
<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"> <div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
<ModelSelector <ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'} selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
groups={ensembleModelGroups} groups={ensembleModelGroups}
label={m.model_ensemble()} label="Ensemble model"
onModelChange={(model) => { onModelChange={(model) => {
params.models = [model]; params.models = [model];
storedEnsembleModel.set(model); storedEnsembleModel.set(model);
}} }}
/> />
</div> </div>
{/snippet} </div>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
@@ -327,7 +327,8 @@
/> />
</svg> </svg>
<span> <span>
{m.ensemble_trimmed({ days: validDays })} This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
trimmed to its available range.
</span> </span>
</div> </div>
{/if} {/if}
@@ -384,9 +385,7 @@
{#snippet controls()} {#snippet controls()}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} /> <Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none" <Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
>{m.legend_show()}</Label
>
</div> </div>
{/snippet} {/snippet}
</ChartToolbar> </ChartToolbar>
+2 -3
View File
@@ -3,19 +3,18 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect // at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of // target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender // being baked to the default city during prerender
onMount(() => { onMount(() => {
goto( goto(
href('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }), resolve('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{ {
replaceState: true replaceState: true
} }
File diff suppressed because it is too large Load Diff
@@ -1,156 +0,0 @@
<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,782 +1,152 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { formatZoned, getZonedHour } from '$lib/utils/date';
import { import { getWeatherIconName } from '../../utils/weather-codes';
groupHover,
groupRange,
registerGroupMember,
setGroupHover,
setGroupRange
} from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { formatZoned } from '$lib/utils/date';
import { getWeatherIconName, hasWeatherIcon } from '../../utils/weather-codes';
import { modelColor, modelLabel } from './comparison';
import type { ModelSeriesData } from '$lib/services/weather';
interface Props { interface Props {
timestamps: number[]; timestamps: number[];
models: ModelSeriesData[]; hourlyFlat: Record<string, number[]>;
modelOrder: string[]; models: string[];
sunrise: number[]; sunrise: number[];
sunset: number[]; sunset: number[];
timezone: string; timezone: string;
group: string;
plotInsetLeft: number;
plotInsetRight: number;
showModelNames: boolean;
onToggleModelNames: () => void;
registerExporter?: (exporter: TimelineExporter | null) => void;
} }
interface TimelineExporter { let { timestamps, hourlyFlat, models, sunrise, sunset, timezone }: Props = $props();
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
let hourlyInterval = $state<1 | 3>(3);
let filteredIndices = $derived(
timestamps.reduce<number[]>((acc, ts, i) => {
if (hourlyInterval === 1 || getZonedHour(new Date(ts), timezone) % 3 === 0) {
acc.push(i);
} }
return acc;
interface TimelinePoint { }, [])
index: number;
time: number;
x: number;
hour: string;
date: string;
}
interface DaySeparator {
x: number;
}
interface SunlightSegment {
x1: number;
x2: number;
}
interface TooltipRow {
modelId: string;
label: string;
color: string;
condition: string;
cloudCover: number | null;
}
let {
timestamps,
models,
modelOrder,
sunrise,
sunset,
timezone,
group,
plotInsetLeft,
plotInsetRight,
showModelNames,
onToggleModelNames,
registerExporter
}: Props = $props();
const HOUR = 3600;
const MIN_SPAN = 2 * HOUR;
const HEADER_H = 38;
const ROW_H = 38;
const FOOTER_H = 5;
const ICON_SIZE = 20;
const ICON_BOX_WIDTH = 20;
const ICON_BOX_HEIGHT = 30;
const MIN_ICON_GAP = 20;
const SUNLIGHT_STRIP_H = 4;
const EXPORT_TITLE_H = 30;
const SAMPLE_STEPS = [1, 2, 3, 4, 6, 8, 12, 24] as const;
let containerEl: HTMLDivElement | undefined = $state();
let canvasEl: HTMLCanvasElement | undefined = $state();
let width = $state(0);
let themeVersion = $state(0);
let dragSelect = $state<{ x0: number; x1: number } | null>(null);
let screenRenderVersion = 0;
let timestampsSec = $derived(timestamps.map((timestamp) => timestamp / 1000));
let displayModels = $derived(
models.filter((model) => model.variables.weather_code?.some(hasWeatherIcon))
); );
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 { function checkNewDay(i: number, ts: number): boolean {
return plotInsetLeft + ((time - viewStart) / Math.max(1, viewEnd - viewStart)) * plotWidth; if (i === 0) return false;
} const prevTs = timestamps[filteredIndices[i - 1]];
return (
function timeForX(x: number): number { formatZoned(new Date(ts), timezone, 'd') !== formatZoned(new Date(prevTs), timezone, 'd')
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; * Determines if it's daytime for each timestamp based on sunrise/sunset data.
let high = values.length - 1; */
while (low < high) { let allDaytimeFlags = $derived(
const middle = (low + high) >> 1; timestamps.map((ts) => {
if (values[middle] < target) low = middle + 1; const tsS = ts / 1000;
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++) { for (let i = 0; i < sunrise.length; i++) {
if (time >= sunrise[i] && time < sunset[i]) return true; const s = sunrise[i];
const nextSunrise = sunrise[i + 1] ?? Infinity; const e = sunset[i];
if (time >= sunset[i] && time < nextSunrise) return false; // Between sunrise and sunset of the same day
} if (tsS >= s && tsS < e) return true;
return sunrise.length === 0 || time >= sunrise[0]; // 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;
let sampleStepHours = $derived.by(() => {
const pixelsPerHour = plotWidth / Math.max(1, (viewEnd - viewStart) / HOUR);
return SAMPLE_STEPS.find((step) => step * pixelsPerHour >= MIN_ICON_GAP) ?? 24;
});
let visiblePoints = $derived.by((): TimelinePoint[] => {
const points: TimelinePoint[] = [];
const edgeInset = ICON_SIZE / 2 + 1;
const firstValidIndex = timestampsSec.findIndex((time) => {
const x = xForTime(time);
return time >= viewStart && time <= viewEnd && x >= plotInsetLeft + edgeInset;
});
if (firstValidIndex < 0) return points;
const firstTime = timestampsSec[firstValidIndex];
const canvasRight = width - edgeInset;
let previousDay = '';
for (let index = firstValidIndex; index < timestampsSec.length; index++) {
const time = timestampsSec[index];
if (time < viewStart || time > viewEnd) continue;
const x = xForTime(time);
if (x > canvasRight) break;
const hoursFromFirst = Math.round((time - firstTime) / HOUR);
if (hoursFromFirst % sampleStepHours !== 0) continue;
const date = new Date(timestamps[index]);
const day = formatZoned(date, timezone, 'yyyy-MM-dd');
points.push({
index,
time,
x,
hour: formatZoned(date, timezone, 'HH'),
date: day === previousDay ? '' : formatZoned(date, timezone, 'EEE d')
});
previousDay = day;
}
return points;
});
let daySeparators = $derived.by((): DaySeparator[] => {
const separators: DaySeparator[] = [];
let previousDay = '';
for (let index = 0; index < timestampsSec.length; index++) {
const time = timestampsSec[index];
if (time < viewStart || time > viewEnd) continue;
const day = formatZoned(new Date(timestamps[index]), timezone, 'yyyy-MM-dd');
if (previousDay && day !== previousDay) separators.push({ x: xForTime(time) });
previousDay = day;
}
return separators;
});
let sunlightSegments = $derived.by((): SunlightSegment[] => {
const segments: SunlightSegment[] = [];
for (let index = 0; index < sunrise.length; index++) {
const start = Math.max(viewStart, sunrise[index]);
const end = Math.min(viewEnd, sunset[index] ?? sunrise[index]);
if (end <= start) continue;
segments.push({ x1: xForTime(start), x2: xForTime(end) });
}
return segments;
});
let hoverX = $derived(
sharedHover !== null && sharedHover >= viewStart && sharedHover <= viewEnd
? xForTime(sharedHover)
: null
);
let hoverIndex = $derived(
sharedHover !== null && hoverX !== null && timestampsSec.length > 0
? nearestIndex(timestampsSec, sharedHover)
: -1
);
function conditionLabel(code: number): string {
if (code === 0) return m.cond_clear();
if (code === 1 || code === 2) return m.cond_fair();
if (code === 3) return m.cond_cloudy();
if (code === 45 || code === 48) return m.cond_fog();
if ([51, 53, 55, 56, 57].includes(code)) return m.cond_drizzle();
if ([71, 73, 75, 77, 85, 86].includes(code)) return m.cond_snow();
if ([95, 96, 99].includes(code)) return m.cond_thunder();
return m.cond_rain();
}
let tooltipRows = $derived.by((): TooltipRow[] => {
if (hoverIndex < 0) return [];
const rows: TooltipRow[] = [];
for (const model of displayModels) {
const code = model.variables.weather_code?.[hoverIndex];
if (!hasWeatherIcon(code)) continue;
const cloudCover = model.variables.cloud_cover?.[hoverIndex];
rows.push({
modelId: model.modelId,
label: modelLabel(model.modelId),
color: modelColor(model.modelId, modelOrder),
condition: conditionLabel(code),
cloudCover:
cloudCover !== undefined && Number.isFinite(cloudCover)
? Math.round(Math.max(0, Math.min(100, cloudCover)))
: null
});
}
return rows;
});
let tooltipVisible = $derived(hoverIndex >= 0 && tooltipRows.length > 0 && hoverX !== null);
let tooltipFlip = $derived((hoverX ?? 0) > width * 0.55);
let hoverFrame = 0;
let queuedClientX = 0;
const pointers = new Map<number, { x: number; y: number }>();
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
let selectionStartX = 0;
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
let panStart: { x: number; start: number; end: number } | null = null;
let pinchStart: { distance: number; start: number; end: number } | null = null;
onMount(() => {
const unregisterGroup = registerGroupMember(group);
const interactionElement = containerEl;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) width = entry.contentRect.width;
});
if (interactionElement) observer.observe(interactionElement);
interactionElement?.addEventListener('wheel', handleWheel, { passive: false });
const themeObserver = new MutationObserver(() => themeVersion++);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme']
});
registerExporter?.({ getExportImage });
return () => {
interactionElement?.removeEventListener('wheel', handleWheel);
observer.disconnect();
themeObserver.disconnect();
unregisterGroup();
registerExporter?.(null);
};
});
function scheduleHover(clientX: number): void {
queuedClientX = clientX;
if (hoverFrame !== 0) return;
hoverFrame = requestAnimationFrame(() => {
hoverFrame = 0;
if (!containerEl || timestampsSec.length === 0) return;
const localX = queuedClientX - containerEl.getBoundingClientRect().left;
const time =
viewStart + ((localX - plotInsetLeft) / Math.max(1, plotWidth)) * (viewEnd - viewStart);
setGroupHover(
group,
localX >= plotInsetLeft && localX <= width - plotInsetRight
? timestampsSec[nearestIndex(timestampsSec, time)]
: null
);
});
}
function handlePointerDown(event: PointerEvent): void {
if (!containerEl) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pointers.size === 2) {
containerEl.setPointerCapture(event.pointerId);
const [first, second] = [...pointers.values()];
pinchStart = {
distance: Math.max(10, Math.abs(first.x - second.x)),
start: viewStart,
end: viewEnd
};
panStart = null;
dragSelect = null;
gesture = 'pinch';
setGroupHover(group, null);
return;
}
if (event.pointerType === 'mouse') {
containerEl.setPointerCapture(event.pointerId);
selectionStartX = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
dragSelect = null;
gesture = 'select';
event.preventDefault();
} else {
touchStart = { x: event.clientX, y: event.clientY, start: viewStart, end: viewEnd };
gesture = 'none';
}
}
function handlePointerMove(event: PointerEvent): void {
if (!containerEl) return;
if (pointers.has(event.pointerId)) {
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
}
if (gesture === 'pinch' && pointers.size === 2 && pinchStart) {
const [first, second] = [...pointers.values()];
const distance = Math.max(10, Math.abs(first.x - second.x));
const scale = pinchStart.distance / distance;
const span = pinchStart.end - pinchStart.start;
const center = (pinchStart.start + pinchStart.end) / 2;
applyRange(center - (span * scale) / 2, center + (span * scale) / 2);
return;
}
if (gesture === 'select' && event.pointerType === 'mouse' && pointers.size === 1) {
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
if (dragSelect || Math.abs(x - selectionStartX) >= 3) {
dragSelect = { x0: selectionStartX, x1: x };
setGroupHover(group, null);
}
return;
}
if (event.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
const deltaX = Math.abs(event.clientX - touchStart.x);
const deltaY = Math.abs(event.clientY - touchStart.y);
if (deltaX < 6 && deltaY < 6) return;
if (deltaY > deltaX) {
gesture = 'scroll';
return;
}
gesture = zoomed ? 'pan' : 'inspect';
containerEl.setPointerCapture(event.pointerId);
if (gesture === 'pan') {
panStart = { x: event.clientX, start: touchStart.start, end: touchStart.end };
}
}
if (gesture === 'scroll') return;
if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) {
const deltaTime =
((panStart.x - event.clientX) / Math.max(1, plotWidth)) *
(panStart.end - panStart.start);
applyRange(panStart.start + deltaTime, panStart.end + deltaTime);
return;
}
if (pointers.size <= 1) scheduleHover(event.clientX);
}
function handlePointerUp(event: PointerEvent): void {
if (gesture === 'select' && event.pointerType === 'mouse' && dragSelect) {
const first = timeForX(dragSelect.x0);
const second = timeForX(dragSelect.x1);
applyRange(Math.min(first, second), Math.max(first, second));
}
dragSelect = null;
pointers.delete(event.pointerId);
if (pointers.size < 2) pinchStart = null;
if (pointers.size === 0) {
panStart = null;
touchStart = null;
gesture = 'none';
}
if (event.pointerType !== 'mouse') setGroupHover(group, null);
}
function handlePointerLeave(): void {
if (pointers.size === 0) setGroupHover(group, null);
}
function handleWheel(event: WheelEvent): void {
if ((!event.ctrlKey && !event.metaKey) || !containerEl) return;
event.preventDefault();
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
zoomAt(timeForX(x), event.deltaY < 0 ? 1 / 1.3 : 1.3);
}
function handleDoubleClick(): void {
setGroupRange(group, null);
}
function handleKeydown(event: KeyboardEvent): void {
if (timestampsSec.length === 0) return;
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const current = sharedHover ?? viewStart;
const index = nearestIndex(timestampsSec, current);
const delta = event.key === 'ArrowLeft' ? -1 : 1;
const next = Math.max(0, Math.min(timestampsSec.length - 1, index + delta));
setGroupHover(group, timestampsSec[next]);
event.preventDefault();
} else if (event.key === '+' || event.key === '=') {
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1 / 1.3);
event.preventDefault();
} else if (event.key === '-' || event.key === '_') {
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1.3);
event.preventDefault();
} else if (event.key === 'Escape') {
setGroupRange(group, null);
setGroupHover(group, null);
}
}
onDestroy(() => {
if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
});
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
const key = `${name}|${color}`;
let promise = iconImageCache.get(key);
if (!promise) {
promise = fetch(`/images/weather-icons/${name}.svg`)
.then((response) => response.text())
.then(
(svg) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const colored = svg.replace(/<svg\b/, `<svg fill="${color}"`);
const url = URL.createObjectURL(new Blob([colored], { type: 'image/svg+xml' }));
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = (error) => {
URL.revokeObjectURL(url);
reject(error);
};
image.src = url;
})
);
iconImageCache.set(key, promise);
}
return promise;
}
export async function getExportImage(opts?: {
title?: string;
}): Promise<HTMLCanvasElement | null> {
if (!containerEl || width <= 0 || displayModels.length === 0) return null;
const dpr = window.devicePixelRatio || 1;
const titleHeight = opts?.title ? EXPORT_TITLE_H : 0;
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * dpr);
canvas.height = Math.round((height + titleHeight) * dpr);
const context = canvas.getContext('2d');
if (!context) return null;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
const styles = getComputedStyle(containerEl);
const color = (name: string, fallback: string): string =>
styles.getPropertyValue(name).trim() || fallback;
const background = color('--card', '#ffffff');
const foreground = color('--foreground', '#1f2937');
const muted = color('--muted-foreground', '#6b7280');
const grid = color('--border', 'rgba(0, 0, 0, 0.12)');
const sunlight = color('--chart-3', '#f6c453');
context.fillStyle = background;
context.fillRect(0, 0, width, height + titleHeight);
if (opts?.title) {
context.fillStyle = foreground;
context.font = '600 14px system-ui, -apple-system, sans-serif';
context.textBaseline = 'middle';
context.fillText(opts.title, 4, EXPORT_TITLE_H / 2 + 2);
}
const yOffset = titleHeight;
context.save();
context.beginPath();
context.rect(plotInsetLeft, yOffset, plotWidth, height);
context.clip();
const sunlightY = yOffset + HEADER_H - SUNLIGHT_STRIP_H;
context.fillStyle = muted;
context.globalAlpha = 0.12;
context.fillRect(plotInsetLeft, sunlightY, plotWidth, SUNLIGHT_STRIP_H);
context.fillStyle = sunlight;
context.globalAlpha = 0.9;
for (const segment of sunlightSegments) {
context.fillRect(segment.x1, sunlightY, segment.x2 - segment.x1, SUNLIGHT_STRIP_H);
}
context.globalAlpha = 1;
context.strokeStyle = grid;
context.lineWidth = 1;
for (const separator of daySeparators) {
context.beginPath();
context.moveTo(separator.x, yOffset);
context.lineTo(separator.x, yOffset + height);
context.stroke();
}
context.restore();
context.font = '600 10px system-ui, -apple-system, sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
for (const point of visiblePoints) {
if (point.date) {
context.fillStyle = foreground;
context.textAlign = 'left';
context.fillText(point.date, Math.max(plotInsetLeft + 3, point.x + 3), yOffset + 9);
}
context.fillStyle = muted;
context.textAlign = 'center';
context.fillText(point.hour, point.x, yOffset + 27);
}
const iconNames = new Set<string>();
for (const model of displayModels) {
for (const point of visiblePoints) {
const code = model.variables.weather_code?.[point.index];
if (!hasWeatherIcon(code)) continue;
iconNames.add(getWeatherIconName(code, isDaytime(point.time)));
}
}
const images = new Map<string, HTMLImageElement>();
await Promise.all(
[...iconNames].map(async (name) => {
try {
images.set(name, await loadColoredIcon(name, foreground));
} catch {
/* An individual missing icon should not prevent the report export. */
} }
// Fallback: before the first sunrise
if (sunrise.length > 0 && tsS < sunrise[0]) return false;
return true;
}) })
); );
context.font = '600 11px system-ui, -apple-system, sans-serif'; /**
context.textBaseline = 'middle'; * Filters models that actually have weather_code data available in the response.
for (let row = 0; row < displayModels.length; row++) { */
const model = displayModels[row]; let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
const rowTop = yOffset + HEADER_H + row * ROW_H;
const rowCenter = rowTop + ROW_H / 2;
context.strokeStyle = grid;
context.globalAlpha = 0.55;
context.beginPath();
context.moveTo(0, rowTop);
context.lineTo(width, rowTop);
context.stroke();
context.globalAlpha = 1;
context.fillStyle = modelColor(model.modelId, modelOrder);
context.beginPath();
context.arc(9, rowCenter, 4, 0, Math.PI * 2);
context.fill();
if (showModelNames) {
context.fillStyle = foreground;
context.textAlign = 'left';
context.fillText(
modelLabel(model.modelId),
18,
rowCenter,
Math.max(20, plotInsetLeft - 24)
);
}
context.save();
context.beginPath();
// The right chart inset is empty display space, so pictograms at the
// final chart coordinate may extend into it without being clipped.
context.rect(plotInsetLeft, rowTop, width - plotInsetLeft, ROW_H);
context.clip();
for (const point of visiblePoints) {
const code = model.variables.weather_code?.[point.index];
if (!hasWeatherIcon(code)) continue;
const cloudCover = model.variables.cloud_cover?.[point.index];
const name = getWeatherIconName(code, isDaytime(point.time));
const image = images.get(name);
if (cloudCover !== undefined && Number.isFinite(cloudCover)) {
const fraction = Math.max(0, Math.min(100, cloudCover)) / 100;
context.fillStyle = muted;
context.globalAlpha = 0.03 + 0.34 * Math.pow(fraction, 0.85);
context.beginPath();
context.roundRect(
point.x - ICON_BOX_WIDTH / 2,
rowCenter - ICON_BOX_HEIGHT / 2,
ICON_BOX_WIDTH,
ICON_BOX_HEIGHT,
4
);
context.fill();
context.globalAlpha = 1;
}
if (image) {
context.drawImage(
image,
point.x - ICON_SIZE / 2,
rowCenter - ICON_SIZE / 2,
ICON_SIZE,
ICON_SIZE
);
}
}
context.restore();
}
return canvas;
}
// The visible panel is drawn through the exact same export renderer. This
// keeps icon sampling, cloud-cover boxes, labels, and plot geometry identical in
// the page and downloaded PNG.
$effect(() => {
const target = canvasEl;
const currentWidth = width;
const currentHeight = height;
void themeVersion;
void visiblePoints;
void daySeparators;
void sunlightSegments;
void displayModels;
void showModelNames;
if (!target || currentWidth <= 0 || currentHeight <= 0) return;
const version = ++screenRenderVersion;
void getExportImage().then((rendered) => {
if (!rendered || version !== screenRenderVersion || target !== canvasEl) return;
target.width = rendered.width;
target.height = rendered.height;
const context = target.getContext('2d');
if (!context) return;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, target.width, target.height);
context.drawImage(rendered, 0, 0);
});
return () => {
if (version === screenRenderVersion) screenRenderVersion++;
};
});
</script> </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} {#if displayModels.length > 0}
<section <div class="mt-8">
class="mt-7 -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border" <div class="mb-4 flex items-center justify-between">
aria-labelledby="model-pictogram-title" <h3 class="text-xl font-bold">Model Comparison Timeline</h3>
> <div class="flex items-center gap-1.5 text-[13px] font-semibold">
<div class="flex items-start justify-between gap-3 px-3 pt-3 pb-2 lg:px-4"> <span class="select-none text-muted-foreground">3h</span>
<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 <button
type="button" class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
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" {hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
aria-pressed={showModelNames} onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
onclick={onToggleModelNames} title="Toggle between 1-hour and 3-hour intervals"
> >
{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 <span
class="size-2 shrink-0 rounded-full" class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
style:background-color={row.color} {hourlyInterval === 1 ? 'left-5.5' : 'left-0.75'}"
></span> ></span>
<span class="min-w-0 break-words">{row.label}:</span> </button>
<span class="pl-2 text-right font-semibold whitespace-nowrap"> <span class="select-none text-muted-foreground">1h</span>
{row.condition}{#if row.cloudCover !== null}
· {m.var_cloud()} {row.cloudCover}%
{/if}
</span>
</div> </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} {/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> </div>
{/if} {/if}
</div>
</div> <style>
</section> th,
{/if} td {
white-space: nowrap;
}
</style>
@@ -1,64 +0,0 @@
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'));
});
});
@@ -1,130 +0,0 @@
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
@@ -1,20 +0,0 @@
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);
});
}
+2 -3
View File
@@ -3,19 +3,18 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import { href } from '$lib/i18n';
// at build time this page knows nothing about the visitor, so the redirect // at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of // target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender // being baked to the default city during prerender
onMount(() => { onMount(() => {
goto( goto(
href('/weather/historical/[location]', { resolve('/weather/historical/[location]', {
location: buildLocationRoute(get(storedLocation)) location: buildLocationRoute(get(storedLocation))
}), }),
{ replaceState: true } { replaceState: true }
@@ -1,12 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { import {
storedArchiveModel,
storedChartLayout, storedChartLayout,
storedLocation, storedLocation,
storedUnits, storedUnits,
@@ -15,9 +12,8 @@
import { ChartContainer } from '$lib/components/charts'; import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte'; import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter'; import { isPremium } from '$lib/paywall/premium';
import { import {
type ClimateNormals, type ClimateNormals,
type HistoricalForecastResult, type HistoricalForecastResult,
@@ -25,10 +21,8 @@
fetchHistoricalWeather fetchHistoricalWeather
} from '$lib/services/weather'; } from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte'; import { defaultParameters } from '../../options';
import { archiveModelGroups, defaultParameters } from '../../options';
import HourlyTable from '../../week/[location]/HourlyTable.svelte'; import HourlyTable from '../../week/[location]/HourlyTable.svelte';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import { neededHourlyApiVars } from '../../week/[location]/variables'; import { neededHourlyApiVars } from '../../week/[location]/variables';
import DateRangeControls from './DateRangeControls.svelte'; import DateRangeControls from './DateRangeControls.svelte';
import HistoricalDaily from './HistoricalDaily.svelte'; import HistoricalDaily from './HistoricalDaily.svelte';
@@ -39,11 +33,6 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
useHeroActions(heroActions);
let location = $derived(data.location); let location = $derived(data.location);
$effect(() => { $effect(() => {
storedLocation.set(data.location); storedLocation.set(data.location);
@@ -86,11 +75,6 @@
mounted = true; mounted = true;
}); });
let archiveModel = $state('best_match');
onMount(() => {
archiveModel = get(storedArchiveModel);
});
function onRangeChange(s: string, e: string) { function onRangeChange(s: string, e: string) {
startDate = s; startDate = s;
endDate = e; endDate = e;
@@ -113,9 +97,7 @@
const s = startDate; const s = startDate;
const e = endDate; const e = endDate;
const vars = hourlyVars; const vars = hourlyVars;
const model = archiveModel; if (!mounted || !$isPremium || !loc || !s || !e) return;
void model;
if (!mounted || !$isSupporter || !loc || !s || !e) return;
const version = ++requestVersion; const version = ++requestVersion;
loading = true; loading = true;
@@ -156,7 +138,7 @@
$effect(() => { $effect(() => {
const key = normalsKey; const key = normalsKey;
const loc = location; const loc = location;
if (!mounted || !$isSupporter || !loc) return; if (!mounted || !$isPremium || !loc) return;
const version = ++normalsVersion; const version = ++normalsVersion;
normals = null; normals = null;
@@ -217,27 +199,34 @@
} }
</script> </script>
<!-- the reanalysis picker rides in the layout's location row -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={archiveModel}
groups={archiveModelGroups}
label={m.model_archive()}
onModelChange={(model) => {
archiveModel = model;
storedArchiveModel.set(model);
}}
/>
</div>
{/snippet}
<svelte:head> <svelte:head>
<title>Drizz.li | {m.page_historical_subtitle()}</title> <title>Drizz.li | Historical weather</title>
<meta name="description" content="Past weather and climate-normal comparisons for any location" /> <meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head> </svelte:head>
<PaywallGate feature={m.page_historical_subtitle()}> <!-- Page hero -->
<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
>Historical weather
</p>
</div>
</div>
</div>
<PaywallGate feature="Historical weather">
<DateRangeControls <DateRangeControls
start={startDate} start={startDate}
end={endDate} end={endDate}
@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import * as m from '$lib/paraglide/messages';
interface Props { interface Props {
start: string; start: string;
end: string; end: string;
@@ -36,10 +34,10 @@
} }
const presets = [ const presets = [
{ label: () => m.historical_last_days({ days: 7 }), apply: () => applyLastDays(7) }, { label: '7 days', apply: () => applyLastDays(7) },
{ label: () => m.historical_last_days({ days: 30 }), apply: () => applyLastDays(30) }, { label: '30 days', apply: () => applyLastDays(30) },
{ label: () => m.historical_last_days({ days: 90 }), apply: () => applyLastDays(90) }, { label: '90 days', apply: () => applyLastDays(90) },
{ label: () => m.historical_month_last_year(), apply: applyThisMonthLastYear } { label: 'Month, last year', apply: applyThisMonthLastYear }
]; ];
// ─── Manual inputs ──────────────────────────────────────────────────────────── // ─── Manual inputs ────────────────────────────────────────────────────────────
@@ -67,9 +65,7 @@
> >
<div class="flex flex-wrap items-end gap-3"> <div class="flex flex-wrap items-end gap-3">
<div class="grid gap-1"> <div class="grid gap-1">
<label for="hist-start" class="text-xs font-semibold text-muted-foreground" <label for="hist-start" class="text-xs font-semibold text-muted-foreground">From</label>
>{m.historical_from()}</label
>
<input <input
id="hist-start" id="hist-start"
type="date" type="date"
@@ -81,9 +77,7 @@
/> />
</div> </div>
<div class="grid gap-1"> <div class="grid gap-1">
<label for="hist-end" class="text-xs font-semibold text-muted-foreground" <label for="hist-end" class="text-xs font-semibold text-muted-foreground">To</label>
>{m.historical_to()}</label
>
<input <input
id="hist-end" id="hist-end"
type="date" type="date"
@@ -99,15 +93,15 @@
<div <div
class="inline-flex flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5 text-xs font-semibold" class="inline-flex flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group" role="group"
aria-label={m.historical_quick_ranges()} aria-label="Quick ranges"
> >
{#each presets as preset (preset.label())} {#each presets as preset (preset.label)}
<button <button
type="button" type="button"
class="cursor-pointer rounded-md px-2.5 py-1.5 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm" class="cursor-pointer rounded-md px-2.5 py-1.5 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
onclick={preset.apply} onclick={preset.apply}
> >
{preset.label()} {preset.label}
</button> </button>
{/each} {/each}
</div> </div>

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