Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2253d002d | ||
|
|
f186c3ff9a | ||
|
|
3f9f1df8f0 | ||
|
|
032b652675 | ||
|
|
83b0e46e64 | ||
|
|
d0f94094ca | ||
|
|
810f9c2605 | ||
|
|
c4a74ba91c | ||
|
|
c3bb6de49c | ||
|
|
1c5ea2b52e | ||
|
|
f4a02a208c | ||
|
|
506831cf88 | ||
|
|
39f36819ae | ||
|
|
b53eb77c75 | ||
|
|
b50eee634e | ||
|
|
025623aaa6 | ||
|
|
dbfa38f6c5 | ||
|
|
9fc17e7417 | ||
|
|
f66e259e32 | ||
|
|
8b58bc01d7 | ||
|
|
edee9fe33d | ||
|
|
5541df20e1 | ||
|
|
77af339df4 | ||
|
|
ea9eb17d75 | ||
|
|
bde815c35a | ||
|
|
ae50999c1c | ||
|
|
3fda55ddd2 | ||
|
|
578ee3c8ce | ||
|
|
01cc6225aa | ||
|
|
b2c8108c8c | ||
|
|
49e011c20b | ||
|
|
d7fe0bafc8 | ||
|
|
ae88140f79 | ||
|
|
17cf8df109 | ||
|
|
0d5dbbc61f | ||
|
|
774afa6c65 | ||
|
|
9806396476 | ||
|
|
fe961a27ae | ||
|
|
792da49cac | ||
|
|
d3b223cbb5 | ||
|
|
5cc9a5428c | ||
|
|
8ad02ea3f9 | ||
|
|
a0379a1fd6 | ||
|
|
99c1a89537 | ||
|
|
2f86d86165 | ||
|
|
315d4e2bdc |
+20
@@ -26,3 +26,23 @@ 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
|
||||||
|
|
||||||
|
# Empty stubs the Claude Code sandbox mounts over shell/editor dotfiles while it
|
||||||
|
# runs. They are not project files and keep sneaking into commits via `git add -A`.
|
||||||
|
/.bash_profile
|
||||||
|
/.bashrc
|
||||||
|
/.profile
|
||||||
|
/.zprofile
|
||||||
|
/.zshrc
|
||||||
|
/.gitconfig
|
||||||
|
/.gitmodules
|
||||||
|
/.ripgreprc
|
||||||
|
/.idea
|
||||||
|
/.mcp.json
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ Pages that are not prerendered (unlisted cities, GPS coordinate routes like
|
|||||||
and resolves the location client-side. Configure the server to serve
|
and resolves the location client-side. Configure the server to serve
|
||||||
`404.html` for unknown paths.
|
`404.html` for unknown paths.
|
||||||
|
|
||||||
|
Serve it as an **internal rewrite (200)**, not as an error page. `error_page
|
||||||
|
404 /404.html` sends the right body with a 404 status: the page works, but
|
||||||
|
every hard reload of an unprerendered URL logs a 404 in the network panel and
|
||||||
|
tells crawlers the page does not exist. `try_files` with a URI as its last
|
||||||
|
argument does an internal redirect instead, and answers 200.
|
||||||
|
|
||||||
### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
|
### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
|
||||||
|
|
||||||
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
|
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
|
||||||
@@ -83,11 +89,12 @@ drizzli.example.com {
|
|||||||
server {
|
server {
|
||||||
server_name drizzli.example.com;
|
server_name drizzli.example.com;
|
||||||
root /srv/drizzli;
|
root /srv/drizzli;
|
||||||
error_page 404 /404.html;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
# the trailing /404.html is a URI, so nginx rewrites internally and
|
||||||
|
# answers 200 - `error_page 404 /404.html` would answer 404 instead
|
||||||
|
try_files $uri $uri/index.html /404.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ 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',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"nav_week": "Wochenvorhersage",
|
||||||
|
"nav_compare": "Modellvergleich",
|
||||||
|
"nav_14day": "14-Tage-Vorhersage",
|
||||||
|
"nav_seasonal": "Saisonal",
|
||||||
|
"nav_historical": "Rückblick",
|
||||||
|
"nav_maps": "Karten",
|
||||||
|
"nav_collapse": "Einklappen",
|
||||||
|
"nav_expand_sidebar": "Seitenleiste ausklappen",
|
||||||
|
"nav_collapse_sidebar": "Seitenleiste einklappen",
|
||||||
|
"nav_home": "Drizzli Startseite",
|
||||||
|
"nav_toggle_menu": "Menü umschalten",
|
||||||
|
"search_placeholder": "Ort suchen …",
|
||||||
|
"settings_title": "Einstellungen",
|
||||||
|
"units_title": "Einheiten",
|
||||||
|
"units_aria": "Maßeinheiten wählen",
|
||||||
|
"unit_temperature": "Temperatur",
|
||||||
|
"unit_wind_speed": "Windgeschwindigkeit",
|
||||||
|
"unit_precipitation": "Niederschlag",
|
||||||
|
"theme_label": "Design",
|
||||||
|
"theme_system": "System",
|
||||||
|
"theme_light": "Hell",
|
||||||
|
"theme_dark": "Dunkel",
|
||||||
|
"theme_follow_system": "Design: System folgen",
|
||||||
|
"theme_light_title": "Design: hell",
|
||||||
|
"theme_dark_title": "Design: dunkel",
|
||||||
|
"language_label": "Sprache",
|
||||||
|
"supporter_active": "Unterstützer-Extras aktiv",
|
||||||
|
"supporter_support": "Drizz.li unterstützen",
|
||||||
|
"supporter_manage_key": "Zugangsschlüssel verwalten",
|
||||||
|
"supporter_unlock": "Unterstützer-Extras freischalten",
|
||||||
|
"supporter_badge": "Unterstützer",
|
||||||
|
"strip_past_label": "zurück",
|
||||||
|
"strip_past_aria": "Die letzten 3 Tage laden",
|
||||||
|
"strip_days_label": "Tage",
|
||||||
|
"strip_extend_aria": "Die vollen 15 Tage anzeigen",
|
||||||
|
"strip_history_label": "Archiv",
|
||||||
|
"strip_history_aria": "Wetterarchiv öffnen",
|
||||||
|
"strip_history_title": "Rückblick: jedes Datum zurück bis 1940",
|
||||||
|
"strip_seasonal_label": "saisonal",
|
||||||
|
"strip_seasonal_aria": "Saisonale Aussichten öffnen",
|
||||||
|
"strip_seasonal_title": "Saisonale Aussichten: Monatstrends für die kommenden Monate",
|
||||||
|
"day_today": "Heute",
|
||||||
|
"day_tomorrow": "Morgen",
|
||||||
|
"day_yesterday": "Gestern",
|
||||||
|
"page_week_subtitle": "Wochenvorhersage",
|
||||||
|
"page_compare_subtitle": "Modellvergleich",
|
||||||
|
"page_14day_subtitle": "14-Tage-Ensemblevorhersage",
|
||||||
|
"page_seasonal_subtitle": "Saisonale Aussichten",
|
||||||
|
"page_historical_subtitle": "Wetterrückblick",
|
||||||
|
"hourly_heading": "stündlich",
|
||||||
|
"hourly_variables": "Variablen",
|
||||||
|
"meteograms_heading": "Meteogramme",
|
||||||
|
"meteograms_customize": "Anpassen",
|
||||||
|
"meteograms_zoom_hint": "Ziehen oder",
|
||||||
|
"meteograms_zoom_hint_end": "+ Scrollen zum Zoomen",
|
||||||
|
"range_today": "Heute",
|
||||||
|
"range_selected_day": "Gewählter Tag",
|
||||||
|
"range_3_days": "3 Tage",
|
||||||
|
"range_5_days": "5 Tage",
|
||||||
|
"range_all": "Alles",
|
||||||
|
"range_group_aria": "Zeitraum des Diagramms",
|
||||||
|
"reset_zoom": "Zoom zurücksetzen",
|
||||||
|
"default_range_title": "Standard-Zeitraum",
|
||||||
|
"default_range_auto": "Auto",
|
||||||
|
"default_range_auto_hint": "3 Tage auf dem Handy, alles auf größeren Bildschirmen",
|
||||||
|
"summary_heading": "in Worten",
|
||||||
|
"summary_no_data": "Für diesen Tag liegen keine Stundenwerte vor.",
|
||||||
|
"label_sunrise": "Sonnenaufgang",
|
||||||
|
"label_sunset": "Sonnenuntergang",
|
||||||
|
"label_daylight": "Tageslicht",
|
||||||
|
"label_uv_index": "UV-Index",
|
||||||
|
"label_moonrise": "Mondaufgang",
|
||||||
|
"label_moonset": "Monduntergang",
|
||||||
|
"label_moon": "Mond",
|
||||||
|
"daylight_hours": "{hours} Std. {minutes} Min.",
|
||||||
|
"sunshine_share": "{percent}% Sonne",
|
||||||
|
"uv_low": "Niedrig",
|
||||||
|
"uv_moderate": "Mäßig",
|
||||||
|
"uv_high": "Hoch",
|
||||||
|
"uv_very_high": "Sehr hoch",
|
||||||
|
"uv_extreme": "Extrem",
|
||||||
|
"moon_new": "Neumond",
|
||||||
|
"moon_waxing_crescent": "Zunehmende Sichel",
|
||||||
|
"moon_first_quarter": "Erstes Viertel",
|
||||||
|
"moon_waxing_gibbous": "Zunehmender Mond",
|
||||||
|
"moon_full": "Vollmond",
|
||||||
|
"moon_waning_gibbous": "Abnehmender Mond",
|
||||||
|
"moon_last_quarter": "Letztes Viertel",
|
||||||
|
"moon_waning_crescent": "Abnehmende Sichel",
|
||||||
|
"cond_clear": "klar",
|
||||||
|
"cond_fair": "teils bewölkt",
|
||||||
|
"cond_cloudy": "bedeckt",
|
||||||
|
"cond_fog": "neblig",
|
||||||
|
"cond_drizzle": "nieselig",
|
||||||
|
"cond_rain": "regnerisch",
|
||||||
|
"cond_snow": "schneereich",
|
||||||
|
"cond_thunder": "stürmisch",
|
||||||
|
"period_overnight": "nachts",
|
||||||
|
"period_morning": "morgens",
|
||||||
|
"period_afternoon": "nachmittags",
|
||||||
|
"period_evening": "in den Abendstunden",
|
||||||
|
"footer_tagline": "Schnelle, gratis, unkomplizierte Wettervorhersagen auf Basis offener Daten.",
|
||||||
|
"footer_data_by": "Wetterdaten von",
|
||||||
|
"footer_forecasts": "Vorhersagen",
|
||||||
|
"footer_popular": "Beliebte Orte",
|
||||||
|
"footer_about_section": "Über",
|
||||||
|
"model_weather": "Wettermodell",
|
||||||
|
"model_ensemble": "Ensemblemodell",
|
||||||
|
"sky_all_1": "Den ganzen Tag {condition}.",
|
||||||
|
"sky_all_2": "Ein durchweg {condition} Tag.",
|
||||||
|
"sky_all_3": "Es bleibt durchgehend {condition}.",
|
||||||
|
"sky_two_1": "{p1} {c1}, {p2} dann {c2}.",
|
||||||
|
"sky_two_2": "Der Tag beginnt {p1} {c1}, bevor es {p2} {c2} wird.",
|
||||||
|
"sky_two_3": "{p1} {c1}, {p2} dann {c2}.",
|
||||||
|
"sky_three_1": "{p1} {c1}, {p2} {c2} und {p3} dann {c3}.",
|
||||||
|
"sky_three_2": "Es beginnt {p1} {c1}, wird {p2} {c2} und endet {p3} {c3}.",
|
||||||
|
"sky_three_3": "{p1} {c1}, {p2} {c2}, {p3} schließlich {c3}.",
|
||||||
|
"temp_1": "Höchstwerte um {high}, nachts Abkühlung auf {low}.",
|
||||||
|
"temp_2": "Die Temperaturen steigen auf {high} und fallen nach Einbruch der Dunkelheit auf {low}.",
|
||||||
|
"temp_3": "Zwischen {low} und {high} im Tagesverlauf.",
|
||||||
|
"temp_feels_1": "Höchstwerte um {high}, nachts {low} - gefühlt eher {feels}.",
|
||||||
|
"temp_feels_2": "Bis {high} am Thermometer, gefühlt {feels}, bevor es auf {low} zurückgeht.",
|
||||||
|
"temp_feels_3": "Es werden {high} erreicht, gefühlt sind es {feels}, später Abkühlung auf {low}.",
|
||||||
|
"precip_window_1": "Rund {amount} Niederschlag, das meiste davon {when}.",
|
||||||
|
"precip_window_2": "Zu erwarten sind etwa {amount}, konzentriert {when}.",
|
||||||
|
"precip_window_3": "Am nassesten wird es {when}, insgesamt rund {amount}.",
|
||||||
|
"precip_spread_1": "Rund {amount} Niederschlag über den Tag verteilt.",
|
||||||
|
"precip_spread_2": "Etwa {amount} fallen in Schüben über den Tag.",
|
||||||
|
"precip_spread_3": "Immer wieder Schauer, zusammen etwa {amount}.",
|
||||||
|
"precip_chance_1": "Überwiegend trocken, mit bis zu {percent}% Schauerwahrscheinlichkeit.",
|
||||||
|
"precip_chance_2": "Kaum Regen zu erwarten, die Schauerwahrscheinlichkeit liegt bei {percent}%.",
|
||||||
|
"precip_chance_3": "{percent}% Schauerrisiko, aber kaum der Rede wert.",
|
||||||
|
"precip_dry_1": "Es bleibt durchweg trocken.",
|
||||||
|
"precip_dry_2": "Kein Tropfen in Sicht.",
|
||||||
|
"precip_dry_3": "Von früh bis spät trocken.",
|
||||||
|
"wind_dir_1": "Wind aus {direction} mit bis zu {speed}.",
|
||||||
|
"wind_dir_2": "Eine Brise aus {direction} erreicht in der Spitze {speed}.",
|
||||||
|
"wind_dir_3": "Der Wind kommt aus {direction} und erreicht Spitzen um {speed}.",
|
||||||
|
"wind_dir_gusts_1": "Wind aus {direction} mit bis zu {speed}, in Böen {gust}.",
|
||||||
|
"wind_dir_gusts_2": "Ein {direction}-Wind von {speed}, in Böen bis {gust}.",
|
||||||
|
"wind_dir_gusts_3": "Aus {direction} sind {speed} zu erwarten, zeitweise Böen von {gust}.",
|
||||||
|
"wind_1": "Wind mit bis zu {speed}.",
|
||||||
|
"wind_2": "Die Brise erreicht rund {speed}.",
|
||||||
|
"wind_3": "Luftbewegung von bis zu {speed}.",
|
||||||
|
"wind_gusts_1": "Wind mit bis zu {speed}, in Böen {gust}.",
|
||||||
|
"wind_gusts_2": "Bis zu {speed}, mit Böen von {gust}.",
|
||||||
|
"wind_gusts_3": "Ein böiger Tag: {speed} im Mittel, {gust} in der Spitze.",
|
||||||
|
"calm_1": "Kaum ein Lüftchen.",
|
||||||
|
"calm_2": "Die Luft bleibt nahezu still.",
|
||||||
|
"calm_3": "Den ganzen Tag so gut wie windstill.",
|
||||||
|
"uv_1": "UV-Index bis {value} ({label}) - mittags an Sonnenschutz denken.",
|
||||||
|
"uv_2": "Mittags brennt die Sonne: UV {value}, {label}.",
|
||||||
|
"uv_3": "Mittags besser in den Schatten - der UV-Index erreicht {value} ({label}).",
|
||||||
|
"legal_about": "Über uns",
|
||||||
|
"legal_imprint": "Impressum",
|
||||||
|
"legal_privacy": "Datenschutz",
|
||||||
|
"legal_terms": "Bedingungen",
|
||||||
|
"legal_nav": "Rechtliches",
|
||||||
|
"city_weather": "Wetter in {city}",
|
||||||
|
"var_temperature": "Temperatur",
|
||||||
|
"var_temperature_short": "Temp",
|
||||||
|
"var_icons": "Wettersymbole",
|
||||||
|
"var_icons_short": "Symbole",
|
||||||
|
"var_apparent": "Gefühlte Temp.",
|
||||||
|
"var_apparent_short": "Gefühlt",
|
||||||
|
"var_dew_point": "Taupunkt",
|
||||||
|
"var_dew_point_short": "Tau",
|
||||||
|
"var_cloud": "Bewölkung",
|
||||||
|
"var_cloud_short": "Wolken",
|
||||||
|
"var_cloud_low": "Bewölkung tief",
|
||||||
|
"var_cloud_low_short": "Tief",
|
||||||
|
"var_cloud_mid": "Bewölkung mittel",
|
||||||
|
"var_cloud_mid_short": "Mittel",
|
||||||
|
"var_cloud_high": "Bewölkung hoch",
|
||||||
|
"var_cloud_high_short": "Hoch",
|
||||||
|
"var_precipitation": "Niederschlag",
|
||||||
|
"var_precipitation_short": "Nied.",
|
||||||
|
"var_pop": "Niederschlagswahrsch.",
|
||||||
|
"var_pop_short": "Wahrsch.",
|
||||||
|
"var_rain": "Regen",
|
||||||
|
"var_rain_short": "Regen",
|
||||||
|
"var_showers": "Schauer",
|
||||||
|
"var_showers_short": "Schauer",
|
||||||
|
"var_snowfall": "Schneefall",
|
||||||
|
"var_snowfall_short": "Schnee",
|
||||||
|
"var_wind": "Windgeschwindigkeit",
|
||||||
|
"var_wind_short": "Wind",
|
||||||
|
"var_wind_dir": "Windrichtung",
|
||||||
|
"var_wind_dir_short": "Richt.",
|
||||||
|
"var_gusts": "Windböen",
|
||||||
|
"var_gusts_short": "Böen",
|
||||||
|
"var_humidity": "Luftfeuchte",
|
||||||
|
"var_humidity_short": "RF",
|
||||||
|
"var_pressure": "Luftdruck (MSL)",
|
||||||
|
"var_pressure_short": "MSLP",
|
||||||
|
"var_surface_pressure": "Bodendruck",
|
||||||
|
"var_surface_pressure_short": "Psfc",
|
||||||
|
"var_uv": "UV-Index",
|
||||||
|
"var_uv_short": "UV",
|
||||||
|
"var_visibility": "Sichtweite",
|
||||||
|
"var_visibility_short": "Sicht",
|
||||||
|
"var_cape": "CAPE",
|
||||||
|
"var_cape_short": "CAPE",
|
||||||
|
"var_time": "Zeit",
|
||||||
|
"no_data_title": "Für dieses Modell gibt es hier keine Daten",
|
||||||
|
"no_data_body": "Das gewählte Wettermodell deckt {location} nicht ab - regionale Modelle liefern nur Daten in ihrem eigenen Gebiet.",
|
||||||
|
"no_data_try_city": "Ort auf {city} ändern",
|
||||||
|
"no_data_best_match": "Zu „Best match“ wechseln",
|
||||||
|
"model_archive": "Reanalyse",
|
||||||
|
"model_seasonal": "Saisonmodell",
|
||||||
|
"nearby_cities_title": "Städte in der Nähe",
|
||||||
|
"nearby_cities_subtitle": "Höchst- und Tiefstwerte für den gewählten Tag",
|
||||||
|
"supporter_dialog_title": "Drizz.li Unterstützer",
|
||||||
|
"supporter_dialog_desc": "Fügen Sie den Zugangsschlüssel aus Ihrer Unterstützer-E-Mail ein, um die Extras freizuschalten.",
|
||||||
|
"supporter_extras_active": "Unterstützer-Extras sind aktiv",
|
||||||
|
"supporter_lifetime": "Lebenslanger Zugang",
|
||||||
|
"supporter_active_until": "Aktiv bis {date}",
|
||||||
|
"supporter_access_key": "Zugangsschlüssel",
|
||||||
|
"supporter_key_invalid": "Dieser Schlüssel ist ungültig oder abgelaufen.",
|
||||||
|
"supporter_server_unreachable": "Der Server ist nicht erreichbar. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
|
||||||
|
"supporter_verifying": "Wird geprüft…",
|
||||||
|
"supporter_unlock_button": "Freischalten",
|
||||||
|
"supporter_remove_key": "Schlüssel von diesem Gerät entfernen",
|
||||||
|
"supporter_no_key_yet": "Noch keinen Schlüssel?",
|
||||||
|
"supporter_support_from": "Das Projekt ab {price} unterstützen",
|
||||||
|
"supporter_checking": "Ihr Zugang wird geprüft…",
|
||||||
|
"supporter_gate_title": "{feature} ist ein Unterstützer-Extra",
|
||||||
|
"supporter_gate_body": "Drizz.li ist kostenlos und quelloffen. Mit einem Beitrag ab {price} halten Sie es am Laufen - als Dankeschön schalten Unterstützer die Extras frei.",
|
||||||
|
"supporter_become": "Unterstützer werden",
|
||||||
|
"supporter_have_key": "Ich habe einen Schlüssel",
|
||||||
|
"supporter_key_expired": "Ihr gespeicherter Schlüssel ist ungültig oder abgelaufen.",
|
||||||
|
"supporter_enter_key": "Geben Sie Ihren Zugangsschlüssel ein.",
|
||||||
|
"supporter_perk_historical": "Historisches Wetter und Vergleich mit Klimanormalen",
|
||||||
|
"supporter_perk_seasonal": "Saisonale Aussichten: kommende Monate gegen die Klimanormale",
|
||||||
|
"supporter_perk_future": "Neue Unterstützer-Extras, sobald sie erscheinen",
|
||||||
|
"action_close": "Schliessen",
|
||||||
|
"action_done": "Fertig",
|
||||||
|
"action_try_again": "Erneut versuchen",
|
||||||
|
"action_reset_defaults": "Auf Standard zurücksetzen",
|
||||||
|
"customize_meteograms": "Meteogramme anpassen",
|
||||||
|
"customizer_intro": "Ziehen Sie Variablen zwischen den Diagrammen, um Ihr eigenes Layout zu bauen.",
|
||||||
|
"customizer_chart_n": "Diagramm {number}",
|
||||||
|
"customizer_delete_chart": "Diagramm {number} löschen",
|
||||||
|
"customizer_add_chart": "+ Diagramm hinzufügen",
|
||||||
|
"customizer_available": "Verfügbare Variablen",
|
||||||
|
"customizer_drag": "{variable} ziehen",
|
||||||
|
"customizer_remove": "{variable} entfernen",
|
||||||
|
"customizer_drop_here": "Variablen hierher ziehen",
|
||||||
|
"customizer_all_in_use": "Alle Variablen sind in Verwendung",
|
||||||
|
"variables_aria": "Variablenauswahl",
|
||||||
|
"variables_close": "Variablenauswahl schliessen",
|
||||||
|
"variables_table_section": "Stundentabelle",
|
||||||
|
"variables_move_up": "{variable} nach oben verschieben",
|
||||||
|
"variables_move_down": "{variable} nach unten verschieben",
|
||||||
|
"variables_charts_hint_before": "Meteogramm-Variablen konfigurieren Sie über die Schaltfläche",
|
||||||
|
"variables_charts_hint_after": "über den Diagrammen.",
|
||||||
|
"table_customize": "Variablen anpassen",
|
||||||
|
"table_interval_aria": "Stundenintervall",
|
||||||
|
"table_now": "Jetzt",
|
||||||
|
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
|
||||||
|
"page_loading": "Wird geladen…",
|
||||||
|
"page_loading_dismiss": "Schließen",
|
||||||
|
"charts_loading": "Diagramme werden geladen…",
|
||||||
|
"chart_download": "Meteogramm als PNG-Bild herunterladen",
|
||||||
|
"chart_credit_viz": "Visualisierung von",
|
||||||
|
"chart_toggle_series": "{series} ein- oder ausblenden",
|
||||||
|
"legend_show": "Legende anzeigen",
|
||||||
|
"meteograms_none_before": "Keine Meteogramme konfiguriert,",
|
||||||
|
"meteograms_none_action": "fügen Sie Variablen hinzu",
|
||||||
|
"meteograms_none_historical": "Keine Meteogramme konfiguriert. Fügen Sie Variablen auf der Wochenvorhersage-Seite hinzu.",
|
||||||
|
"search_searching": "Wird gesucht…",
|
||||||
|
"search_favorites": "Favoriten",
|
||||||
|
"search_recent": "Zuletzt",
|
||||||
|
"search_hint": "Tippen Sie los oder nutzen Sie GPS, um Ihren Standort zu bestimmen",
|
||||||
|
"search_no_results": "Keine Orte gefunden",
|
||||||
|
"search_remove_recent": "{location} aus den letzten Orten entfernen",
|
||||||
|
"search_remove_recent_short": "Aus den letzten entfernen",
|
||||||
|
"search_aria": "Ort suchen",
|
||||||
|
"search_gps": "GPS-Standort verwenden",
|
||||||
|
"model_automatic_selection": "Automatische Auswahl",
|
||||||
|
"model_selector_aria": "Auswahl: {label}",
|
||||||
|
"compare_models_heading": "Modelle",
|
||||||
|
"compare_models_choose": "Modelle für den Vergleich wählen",
|
||||||
|
"compare_variables_heading": "Stündliche Wettervariablen",
|
||||||
|
"compare_standard_preset": "Standardvergleich",
|
||||||
|
"compare_weather_conditions": "Wetterlage & Gesamtbewölkung",
|
||||||
|
"compare_wind_direction_height": "Windrichtung in {height} m",
|
||||||
|
"compare_standard_preset_description": "Ein ausgewogener Ausgangspunkt für den Vergleich von Vorhersagemodellen.",
|
||||||
|
"compare_preset_active": "Aktiv",
|
||||||
|
"compare_custom_selection": "Benutzerdefiniert · {count} ausgewählt",
|
||||||
|
"compare_customize_variables": "Variablen anpassen",
|
||||||
|
"compare_restore_defaults": "Standard wiederherstellen",
|
||||||
|
"compare_variables_pending": "Variablenauswahl geändert",
|
||||||
|
"compare_selected_models": "Ausgewählte Modelle",
|
||||||
|
"compare_selection_pending": "Auswahl geändert",
|
||||||
|
"compare_apply_selection": "Anwenden & Diagramme neu laden",
|
||||||
|
"compare_discard_changes": "Änderungen verwerfen",
|
||||||
|
"compare_customize_models": "Modelle anpassen",
|
||||||
|
"compare_edit_models": "Modelle bearbeiten",
|
||||||
|
"compare_edit_variables": "Variablen bearbeiten",
|
||||||
|
"compare_edit_models_description": "Modelle suchen oder ganze Anbietergruppen auswählen.",
|
||||||
|
"compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.",
|
||||||
|
"compare_search_models": "Modelle suchen",
|
||||||
|
"compare_search_variables": "Variablen suchen",
|
||||||
|
"compare_show_model_names": "Modellnamen anzeigen",
|
||||||
|
"compare_hide_model_names": "Modellnamen ausblenden",
|
||||||
|
"compare_only_selected": "Nur ausgewählte",
|
||||||
|
"compare_select_group": "Alle auswählen",
|
||||||
|
"compare_clear_group": "Alle abwählen",
|
||||||
|
"compare_no_matching_options": "Keine passenden Optionen.",
|
||||||
|
"compare_no_models_selected": "Keine Modelle ausgewählt.",
|
||||||
|
"compare_remove_model": "{model} entfernen",
|
||||||
|
"compare_timeline_title": "Zeitleiste des Modellvergleichs",
|
||||||
|
"compare_model_label": "Modell",
|
||||||
|
"page_compare_title": "Wettermodelle vergleichen",
|
||||||
|
"page_compare_description": "Stündliche Vorhersagen mehrerer Wettermodelle für jeden Ort vergleichen.",
|
||||||
|
"compare_direction_subtitle": "Richtungsstreuung über {count} Modelle · kein Mittelwert",
|
||||||
|
"compare_scalar_subtitle": "Über {count} Modelle · gestrichelt = Modellmittel",
|
||||||
|
"compare_precipitation_subtitle": "Über {count} Modelle · unterer Streifen = Niederschlagsübereinstimmung",
|
||||||
|
"compare_precipitation_agreement": "Übereinstimmung",
|
||||||
|
"compare_precipitation_agreement_tooltip": "{wet}/{available} mit Niederschlag · Median {median} {unit} · Spanne {min}–{max} {unit}",
|
||||||
|
"compare_model_mean": "Modellmittel",
|
||||||
|
"compare_showing_previous": "Der vorherige erfolgreiche Vergleich bleibt sichtbar.",
|
||||||
|
"compare_empty_selection": "Mindestens ein Modell und eine Wettervariable auswählen.",
|
||||||
|
"compare_no_data": "Keines der ausgewählten Modelle liefert für diesen Ort und diese Auswahl nutzbare Daten.",
|
||||||
|
"compare_weather_codes_only": "Wetterlage und Gesamtbewölkung erscheinen unten in der Modellzeitleiste. Für ein Diagramm eine weitere Variable auswählen.",
|
||||||
|
"compare_model_colors": "Modellfarben",
|
||||||
|
"compare_group_temperature": "Temperatur & Feuchte",
|
||||||
|
"compare_group_precipitation": "Niederschlag & Wetter",
|
||||||
|
"compare_group_clouds": "Druck & Wolken",
|
||||||
|
"compare_group_wind": "Wind & Atmosphäre",
|
||||||
|
"compare_group_upper_air": "Temperatur in der Höhe",
|
||||||
|
"compare_many_models_warning": "{count} Modelle sind ausgewählt. Der Vergleich kann langsamer und die Farben schwerer unterscheidbar sein.",
|
||||||
|
"compare_timeline_hint": "Wetter nach Modell; der Piktogramm-Hintergrund zeigt die Gesamtbewölkung",
|
||||||
|
"compare_timeline_scroll_aria": "Scrollbare Wetterzeitleiste der Modelle",
|
||||||
|
"compare_timeline_caption": "Stündliche Wetterbedingungen nach Vorhersagemodell",
|
||||||
|
"ensemble_trimmed": "Das Ensemble dieses Modells reicht nur etwa {days} Tage voraus, die Streuung ist auf den verfügbaren Zeitraum gekürzt.",
|
||||||
|
"seasonal_explainer_before": "Eine saisonale Vorhersage zeigt, wie stark ein ganzer Monat voraussichtlich",
|
||||||
|
"seasonal_explainer_strong": "von seiner Klimanormalen abweicht",
|
||||||
|
"seasonal_explainer_after": "- nicht das Wetter an einem einzelnen Tag. Lesen Sie den Monatstrend und die Übereinstimmung des Ensembles, nicht die täglichen Ausschläge.",
|
||||||
|
"seasonal_runs_to": "Diese Aussicht reicht bis {date}.",
|
||||||
|
"seasonal_range_aria": "Zeitraum der Aussichten",
|
||||||
|
"seasonal_no_normal": "keine Normale für diesen Monat",
|
||||||
|
"normals_loading": "Normale wird geladen…",
|
||||||
|
"normals_loading_period": "Normale 1991–2020 wird geladen…",
|
||||||
|
"normal_band_legend": "Normalband = Mittel 1991–2020",
|
||||||
|
"anomaly_vs_normal": "{value} gegenüber Normal",
|
||||||
|
"stat_avg_temperature": "Durchschnittstemperatur",
|
||||||
|
"stat_mean_temperature": "Mitteltemperatur",
|
||||||
|
"stat_total_precipitation": "Niederschlagssumme",
|
||||||
|
"stat_warmest_day": "Wärmster Tag",
|
||||||
|
"stat_coldest_day": "Kältester Tag",
|
||||||
|
"historical_daily_heading": "Täglich",
|
||||||
|
"historical_daily_hint": "– Tag wählen für Stundenwerte",
|
||||||
|
"historical_full_range": "– gesamter Zeitraum",
|
||||||
|
"historical_from": "Von",
|
||||||
|
"historical_to": "Bis",
|
||||||
|
"historical_quick_ranges": "Schnellauswahl",
|
||||||
|
"historical_last_days": "{days} Tage",
|
||||||
|
"historical_month_last_year": "Monat, Vorjahr",
|
||||||
|
"label_day": "Tag",
|
||||||
|
"label_night": "Nacht",
|
||||||
|
"daycards_past": "Letzte 3 Tage",
|
||||||
|
"daycards_load_15": "15 Tage laden",
|
||||||
|
"error_technical_details": "Technische Details",
|
||||||
|
"err_network_title": "Der Wetterdienst ist nicht erreichbar",
|
||||||
|
"err_network_hint": "Prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.",
|
||||||
|
"err_nodata_title": "Keine Daten für diesen Ort mit dem gewählten Modell",
|
||||||
|
"err_nodata_hint": "Regionale Wettermodelle decken nur ihr eigenes Gebiet ab - \"Best match\" wählt automatisch ein passendes Modell.",
|
||||||
|
"err_rejected_title": "Der Wetterdienst hat die Anfrage abgelehnt",
|
||||||
|
"err_rejected_hint": "Versuchen Sie andere Einstellungen oder stellen Sie das Modell auf \"Best match\" zurück.",
|
||||||
|
"err_generic_title": "Die Wetterdaten konnten nicht geladen werden",
|
||||||
|
"err_generic_hint": "Versuchen Sie es gleich noch einmal. Wenn es weiterhin auftritt, wechseln Sie zum Modell \"Best match\".",
|
||||||
|
"maps_iframe_title": "Interaktive Karte von Open-Meteo",
|
||||||
|
"page_maps_title": "Wetterkarte",
|
||||||
|
"page_week_title": "Wetter",
|
||||||
|
"search_favorite_add": "Zu Favoriten hinzufügen",
|
||||||
|
"search_favorite_remove": "Aus Favoriten entfernen",
|
||||||
|
"supporter_price_per_month": "{amount} / Monat",
|
||||||
|
"model_best_match_hint": "Wählt automatisch das beste Modell für diesen Ort",
|
||||||
|
"model_updated": "aktualisiert {cadence}",
|
||||||
|
"cadence_every_hour": "stündlich",
|
||||||
|
"cadence_every_hours": "alle {hours} h",
|
||||||
|
"cadence_daily": "täglich",
|
||||||
|
"cadence_monthly": "monatlich",
|
||||||
|
"cadence_varies": "variiert",
|
||||||
|
"model_group_automatic": "Automatisch",
|
||||||
|
"model_group_reanalysis": "ECMWF-Reanalyse",
|
||||||
|
"model_group_regional_reanalysis": "Regionale Reanalyse",
|
||||||
|
"wmo_0": "Klarer Himmel",
|
||||||
|
"wmo_1": "Überwiegend klar",
|
||||||
|
"wmo_2": "Teils bewölkt",
|
||||||
|
"wmo_3": "Bedeckt",
|
||||||
|
"wmo_45": "Nebel",
|
||||||
|
"wmo_48": "Gefrierender Nebel",
|
||||||
|
"wmo_51": "Leichter Nieselregen",
|
||||||
|
"wmo_53": "Mäßiger Nieselregen",
|
||||||
|
"wmo_55": "Starker Nieselregen",
|
||||||
|
"wmo_56": "Leichter gefrierender Nieselregen",
|
||||||
|
"wmo_57": "Starker gefrierender Nieselregen",
|
||||||
|
"wmo_61": "Leichter Regen",
|
||||||
|
"wmo_63": "Mäßiger Regen",
|
||||||
|
"wmo_65": "Starker Regen",
|
||||||
|
"wmo_66": "Leichter gefrierender Regen",
|
||||||
|
"wmo_67": "Starker gefrierender Regen",
|
||||||
|
"wmo_71": "Leichter Schneefall",
|
||||||
|
"wmo_73": "Mäßiger Schneefall",
|
||||||
|
"wmo_75": "Starker Schneefall",
|
||||||
|
"wmo_77": "Schneegriesel",
|
||||||
|
"wmo_80": "Leichte Regenschauer",
|
||||||
|
"wmo_81": "Mäßige Regenschauer",
|
||||||
|
"wmo_82": "Heftige Regenschauer",
|
||||||
|
"wmo_85": "Leichte Schneeschauer",
|
||||||
|
"wmo_86": "Starke Schneeschauer",
|
||||||
|
"wmo_95": "Gewitter",
|
||||||
|
"wmo_96": "Gewitter mit leichtem Hagel",
|
||||||
|
"wmo_99": "Gewitter mit starkem Hagel",
|
||||||
|
"summary_read_more": "Mehr anzeigen",
|
||||||
|
"summary_read_less": "Weniger anzeigen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"nav_week": "Weekly Forecast",
|
||||||
|
"nav_compare": "Model Comparison",
|
||||||
|
"nav_14day": "14-Day Forecast",
|
||||||
|
"nav_seasonal": "Seasonal",
|
||||||
|
"nav_historical": "Historical",
|
||||||
|
"nav_maps": "Maps",
|
||||||
|
"nav_collapse": "Collapse",
|
||||||
|
"nav_expand_sidebar": "Expand sidebar",
|
||||||
|
"nav_collapse_sidebar": "Collapse sidebar",
|
||||||
|
"nav_home": "Drizzli home",
|
||||||
|
"nav_toggle_menu": "Toggle menu",
|
||||||
|
"search_placeholder": "Search location...",
|
||||||
|
"settings_title": "Settings",
|
||||||
|
"units_title": "Units",
|
||||||
|
"units_aria": "Choose measurement units",
|
||||||
|
"unit_temperature": "Temperature",
|
||||||
|
"unit_wind_speed": "Wind speed",
|
||||||
|
"unit_precipitation": "Precipitation",
|
||||||
|
"theme_label": "Theme",
|
||||||
|
"theme_system": "System",
|
||||||
|
"theme_light": "Light",
|
||||||
|
"theme_dark": "Dark",
|
||||||
|
"theme_follow_system": "Theme: follow system",
|
||||||
|
"theme_light_title": "Theme: light",
|
||||||
|
"theme_dark_title": "Theme: dark",
|
||||||
|
"language_label": "Language",
|
||||||
|
"supporter_active": "Supporter extras active",
|
||||||
|
"supporter_support": "Support Drizz.li",
|
||||||
|
"supporter_manage_key": "Manage your access key",
|
||||||
|
"supporter_unlock": "Unlock the supporter extras",
|
||||||
|
"supporter_badge": "Supporter",
|
||||||
|
"strip_past_label": "past",
|
||||||
|
"strip_past_aria": "Load the past 3 days",
|
||||||
|
"strip_days_label": "days",
|
||||||
|
"strip_extend_aria": "Show the full 15-day forecast",
|
||||||
|
"strip_history_label": "history",
|
||||||
|
"strip_history_aria": "Open the historical weather archive",
|
||||||
|
"strip_history_title": "Historical weather: any past date back to 1940",
|
||||||
|
"strip_seasonal_label": "seasonal",
|
||||||
|
"strip_seasonal_aria": "Open the seasonal outlook",
|
||||||
|
"strip_seasonal_title": "Seasonal outlook: monthly trends for the months ahead",
|
||||||
|
"day_today": "Today",
|
||||||
|
"day_tomorrow": "Tomorrow",
|
||||||
|
"day_yesterday": "Yesterday",
|
||||||
|
"page_week_subtitle": "Weekly forecast",
|
||||||
|
"page_compare_subtitle": "Model comparison",
|
||||||
|
"page_14day_subtitle": "14-day ensemble forecast",
|
||||||
|
"page_seasonal_subtitle": "Seasonal outlook",
|
||||||
|
"page_historical_subtitle": "Historical weather",
|
||||||
|
"hourly_heading": "hourly",
|
||||||
|
"hourly_variables": "Variables",
|
||||||
|
"meteograms_heading": "Meteograms",
|
||||||
|
"meteograms_customize": "Customize",
|
||||||
|
"meteograms_zoom_hint": "drag or",
|
||||||
|
"meteograms_zoom_hint_end": "+ scroll to zoom",
|
||||||
|
"range_today": "Today",
|
||||||
|
"range_selected_day": "Selected day",
|
||||||
|
"range_3_days": "3 days",
|
||||||
|
"range_5_days": "5 days",
|
||||||
|
"range_all": "All",
|
||||||
|
"range_group_aria": "Chart time range",
|
||||||
|
"reset_zoom": "Reset zoom",
|
||||||
|
"default_range_title": "Default time range",
|
||||||
|
"default_range_auto": "Auto",
|
||||||
|
"default_range_auto_hint": "3 days on phones, everything on wider screens",
|
||||||
|
"summary_heading": "in words",
|
||||||
|
"summary_no_data": "No hourly detail available for this day.",
|
||||||
|
"label_sunrise": "Sunrise",
|
||||||
|
"label_sunset": "Sunset",
|
||||||
|
"label_daylight": "Daylight",
|
||||||
|
"label_uv_index": "UV index",
|
||||||
|
"label_moonrise": "Moonrise",
|
||||||
|
"label_moonset": "Moonset",
|
||||||
|
"label_moon": "Moon",
|
||||||
|
"daylight_hours": "{hours} h {minutes} m",
|
||||||
|
"sunshine_share": "{percent}% sun",
|
||||||
|
"uv_low": "Low",
|
||||||
|
"uv_moderate": "Moderate",
|
||||||
|
"uv_high": "High",
|
||||||
|
"uv_very_high": "Very high",
|
||||||
|
"uv_extreme": "Extreme",
|
||||||
|
"moon_new": "New moon",
|
||||||
|
"moon_waxing_crescent": "Waxing crescent",
|
||||||
|
"moon_first_quarter": "First quarter",
|
||||||
|
"moon_waxing_gibbous": "Waxing gibbous",
|
||||||
|
"moon_full": "Full moon",
|
||||||
|
"moon_waning_gibbous": "Waning gibbous",
|
||||||
|
"moon_last_quarter": "Last quarter",
|
||||||
|
"moon_waning_crescent": "Waning crescent",
|
||||||
|
"cond_clear": "clear",
|
||||||
|
"cond_fair": "partly cloudy",
|
||||||
|
"cond_cloudy": "overcast",
|
||||||
|
"cond_fog": "foggy",
|
||||||
|
"cond_drizzle": "drizzly",
|
||||||
|
"cond_rain": "wet",
|
||||||
|
"cond_snow": "snowy",
|
||||||
|
"cond_thunder": "stormy",
|
||||||
|
"period_overnight": "overnight",
|
||||||
|
"period_morning": "in the morning",
|
||||||
|
"period_afternoon": "in the afternoon",
|
||||||
|
"period_evening": "in the evening",
|
||||||
|
"footer_tagline": "Fast, free, no-nonsense weather forecasts built on open data.",
|
||||||
|
"footer_data_by": "Weather data by",
|
||||||
|
"footer_forecasts": "Forecasts",
|
||||||
|
"footer_popular": "Popular locations",
|
||||||
|
"footer_about_section": "About",
|
||||||
|
"model_weather": "Weather model",
|
||||||
|
"model_ensemble": "Ensemble model",
|
||||||
|
"sky_all_1": "{condition} all day.",
|
||||||
|
"sky_all_2": "A {condition} day from start to finish.",
|
||||||
|
"sky_all_3": "It stays {condition} throughout.",
|
||||||
|
"sky_two_1": "{c1} {p1}, turning {c2} {p2}.",
|
||||||
|
"sky_two_2": "The day opens {c1} {p1} before turning {c2} {p2}.",
|
||||||
|
"sky_two_3": "Expect {c1} skies {p1}, then {c2} {p2}.",
|
||||||
|
"sky_three_1": "{c1} {p1}, turning {c2} {p2}, then {c3} {p3}.",
|
||||||
|
"sky_three_2": "It starts {c1} {p1}, becomes {c2} {p2} and ends up {c3} {p3}.",
|
||||||
|
"sky_three_3": "From {c1} skies {p1} to {c2} {p2}, before settling {c3} {p3}.",
|
||||||
|
"temp_1": "Highs near {high}, dropping to {low} overnight.",
|
||||||
|
"temp_2": "Temperatures climb to {high} and fall back to {low} after dark.",
|
||||||
|
"temp_3": "Between {low} and {high} through the day.",
|
||||||
|
"temp_feels_1": "Highs near {high}, down to {low} overnight, though it feels closer to {feels}.",
|
||||||
|
"temp_feels_2": "Up to {high} on the thermometer - closer to {feels} in the air - before easing to {low}.",
|
||||||
|
"temp_feels_3": "It reaches {high} but feels more like {feels}, cooling to {low} later.",
|
||||||
|
"precip_window_1": "Around {amount} of precipitation, most of it {when}.",
|
||||||
|
"precip_window_2": "Expect about {amount}, concentrated {when}.",
|
||||||
|
"precip_window_3": "The wettest stretch is {when}, adding up to roughly {amount}.",
|
||||||
|
"precip_spread_1": "Around {amount} of precipitation spread across the day.",
|
||||||
|
"precip_spread_2": "Roughly {amount} falls in bursts through the day.",
|
||||||
|
"precip_spread_3": "Showers come and go, totalling about {amount}.",
|
||||||
|
"precip_chance_1": "Mostly dry, with up to a {percent}% chance of catching a shower.",
|
||||||
|
"precip_chance_2": "Little rain expected, though there is a {percent}% chance of a passing shower.",
|
||||||
|
"precip_chance_3": "A {percent}% shower risk, but nothing worth an umbrella.",
|
||||||
|
"precip_dry_1": "Staying dry throughout.",
|
||||||
|
"precip_dry_2": "Not a drop expected.",
|
||||||
|
"precip_dry_3": "Dry from start to finish.",
|
||||||
|
"wind_dir_1": "Wind from the {direction} reaching {speed}.",
|
||||||
|
"wind_dir_2": "A breeze out of the {direction} tops out at {speed}.",
|
||||||
|
"wind_dir_3": "The wind sits in the {direction}, peaking near {speed}.",
|
||||||
|
"wind_dir_gusts_1": "Wind from the {direction} reaching {speed}, with gusts to {gust}.",
|
||||||
|
"wind_dir_gusts_2": "Wind out of the {direction} at {speed}, gusting as high as {gust}.",
|
||||||
|
"wind_dir_gusts_3": "Expect {speed} from the {direction}, gusting {gust} at times.",
|
||||||
|
"wind_1": "Wind reaching {speed}.",
|
||||||
|
"wind_2": "The breeze peaks around {speed}.",
|
||||||
|
"wind_3": "Air moving at up to {speed}.",
|
||||||
|
"wind_gusts_1": "Wind reaching {speed}, with gusts to {gust}.",
|
||||||
|
"wind_gusts_2": "Up to {speed}, with gusts punching through at {gust}.",
|
||||||
|
"wind_gusts_3": "A gusty day: {speed} sustained, {gust} at the peaks.",
|
||||||
|
"calm_1": "Barely a breath of wind.",
|
||||||
|
"calm_2": "The air stays almost still.",
|
||||||
|
"calm_3": "Next to no wind all day.",
|
||||||
|
"uv_1": "UV peaks at {value} ({label}), so cover up around midday.",
|
||||||
|
"uv_2": "The sun bites around noon: UV {value}, {label}.",
|
||||||
|
"uv_3": "Worth some shade in the middle of the day - UV reaches {value} ({label}).",
|
||||||
|
"legal_about": "About",
|
||||||
|
"legal_imprint": "Imprint",
|
||||||
|
"legal_privacy": "Privacy",
|
||||||
|
"legal_terms": "Terms",
|
||||||
|
"legal_nav": "Legal",
|
||||||
|
"city_weather": "{city} weather",
|
||||||
|
"var_temperature": "Temperature",
|
||||||
|
"var_temperature_short": "Temp",
|
||||||
|
"var_icons": "Weather icons",
|
||||||
|
"var_icons_short": "Icons",
|
||||||
|
"var_apparent": "Apparent Temp",
|
||||||
|
"var_apparent_short": "Feels",
|
||||||
|
"var_dew_point": "Dew Point",
|
||||||
|
"var_dew_point_short": "Dew",
|
||||||
|
"var_cloud": "Cloud Cover",
|
||||||
|
"var_cloud_short": "Cloud",
|
||||||
|
"var_cloud_low": "Cloud Cover Low",
|
||||||
|
"var_cloud_low_short": "Low",
|
||||||
|
"var_cloud_mid": "Cloud Cover Mid",
|
||||||
|
"var_cloud_mid_short": "Mid",
|
||||||
|
"var_cloud_high": "Cloud Cover High",
|
||||||
|
"var_cloud_high_short": "High",
|
||||||
|
"var_precipitation": "Precipitation",
|
||||||
|
"var_precipitation_short": "Precip",
|
||||||
|
"var_pop": "Precip. Probability",
|
||||||
|
"var_pop_short": "PoP",
|
||||||
|
"var_rain": "Rain",
|
||||||
|
"var_rain_short": "Rain",
|
||||||
|
"var_showers": "Showers",
|
||||||
|
"var_showers_short": "Shwr",
|
||||||
|
"var_snowfall": "Snowfall",
|
||||||
|
"var_snowfall_short": "Snow",
|
||||||
|
"var_wind": "Wind Speed",
|
||||||
|
"var_wind_short": "Wind",
|
||||||
|
"var_wind_dir": "Wind direction",
|
||||||
|
"var_wind_dir_short": "Dir",
|
||||||
|
"var_gusts": "Wind Gusts",
|
||||||
|
"var_gusts_short": "Gusts",
|
||||||
|
"var_humidity": "Humidity",
|
||||||
|
"var_humidity_short": "RH",
|
||||||
|
"var_pressure": "Pressure (MSL)",
|
||||||
|
"var_pressure_short": "MSLP",
|
||||||
|
"var_surface_pressure": "Surface Pressure",
|
||||||
|
"var_surface_pressure_short": "Psfc",
|
||||||
|
"var_uv": "UV Index",
|
||||||
|
"var_uv_short": "UV",
|
||||||
|
"var_visibility": "Visibility",
|
||||||
|
"var_visibility_short": "Vis",
|
||||||
|
"var_cape": "CAPE",
|
||||||
|
"var_cape_short": "CAPE",
|
||||||
|
"var_time": "Time",
|
||||||
|
"no_data_title": "No forecast data for this model here",
|
||||||
|
"no_data_body": "The selected weather model doesn't cover {location} - regional models only provide data inside their own area.",
|
||||||
|
"no_data_try_city": "Change location to {city}",
|
||||||
|
"no_data_best_match": "Switch to Best match",
|
||||||
|
"model_archive": "Reanalysis",
|
||||||
|
"model_seasonal": "Seasonal model",
|
||||||
|
"nearby_cities_title": "Nearby cities",
|
||||||
|
"nearby_cities_subtitle": "Highs and lows for the selected day",
|
||||||
|
"supporter_dialog_title": "Drizz.li Supporter",
|
||||||
|
"supporter_dialog_desc": "Paste the access key from your supporter email to unlock the extras.",
|
||||||
|
"supporter_extras_active": "Supporter extras are active",
|
||||||
|
"supporter_lifetime": "Lifetime access",
|
||||||
|
"supporter_active_until": "Active until {date}",
|
||||||
|
"supporter_access_key": "Access key",
|
||||||
|
"supporter_key_invalid": "That key is not valid or has expired.",
|
||||||
|
"supporter_server_unreachable": "Couldn't reach the server. Check your connection and try again.",
|
||||||
|
"supporter_verifying": "Verifying…",
|
||||||
|
"supporter_unlock_button": "Unlock",
|
||||||
|
"supporter_remove_key": "Remove key from this device",
|
||||||
|
"supporter_no_key_yet": "No key yet?",
|
||||||
|
"supporter_support_from": "Support the project from {price}",
|
||||||
|
"supporter_checking": "Checking your subscription…",
|
||||||
|
"supporter_gate_title": "{feature} is a supporter extra",
|
||||||
|
"supporter_gate_body": "Drizz.li is free and open source. Chip in from {price} to keep it running - as a thank-you, supporters unlock the extras.",
|
||||||
|
"supporter_become": "Become a supporter",
|
||||||
|
"supporter_have_key": "I have a key",
|
||||||
|
"supporter_key_expired": "Your saved key is no longer valid or has expired.",
|
||||||
|
"supporter_enter_key": "Enter your access key.",
|
||||||
|
"supporter_perk_historical": "Historical weather & climate-normal comparisons",
|
||||||
|
"supporter_perk_seasonal": "Seasonal outlook: months ahead vs the climate normal",
|
||||||
|
"supporter_perk_future": "New supporter extras as they land",
|
||||||
|
"action_close": "Close",
|
||||||
|
"action_done": "Done",
|
||||||
|
"action_try_again": "Try again",
|
||||||
|
"action_reset_defaults": "Reset to defaults",
|
||||||
|
"customize_meteograms": "Customize meteograms",
|
||||||
|
"customizer_intro": "Drag variables between charts to build your own layout.",
|
||||||
|
"customizer_chart_n": "Chart {number}",
|
||||||
|
"customizer_delete_chart": "Delete chart {number}",
|
||||||
|
"customizer_add_chart": "+ Add chart",
|
||||||
|
"customizer_available": "Available variables",
|
||||||
|
"customizer_drag": "Drag {variable}",
|
||||||
|
"customizer_remove": "Remove {variable}",
|
||||||
|
"customizer_drop_here": "Drop variables here",
|
||||||
|
"customizer_all_in_use": "All variables are in use",
|
||||||
|
"variables_aria": "Variable selection",
|
||||||
|
"variables_close": "Close variable selection",
|
||||||
|
"variables_table_section": "Hourly table",
|
||||||
|
"variables_move_up": "Move {variable} up",
|
||||||
|
"variables_move_down": "Move {variable} down",
|
||||||
|
"variables_charts_hint_before": "Meteogram variables are configured with the",
|
||||||
|
"variables_charts_hint_after": "button above the charts.",
|
||||||
|
"table_customize": "Customize variables",
|
||||||
|
"table_interval_aria": "Hourly interval",
|
||||||
|
"table_now": "Now",
|
||||||
|
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
|
||||||
|
"page_loading": "Loading…",
|
||||||
|
"page_loading_dismiss": "Dismiss",
|
||||||
|
"charts_loading": "Loading charts...",
|
||||||
|
"chart_download": "Download meteogram as PNG image",
|
||||||
|
"chart_credit_viz": "visualisation by",
|
||||||
|
"chart_toggle_series": "Toggle {series}",
|
||||||
|
"legend_show": "Show legend",
|
||||||
|
"meteograms_none_before": "No meteograms configured,",
|
||||||
|
"meteograms_none_action": "add some variables",
|
||||||
|
"meteograms_none_historical": "No meteograms configured. Add variables from the weekly forecast page.",
|
||||||
|
"search_searching": "Searching...",
|
||||||
|
"search_favorites": "Favorites",
|
||||||
|
"search_recent": "Recent",
|
||||||
|
"search_hint": "Start typing to search or use GPS to detect your position",
|
||||||
|
"search_no_results": "No locations found",
|
||||||
|
"search_remove_recent": "Remove {location} from recent locations",
|
||||||
|
"search_remove_recent_short": "Remove from recent",
|
||||||
|
"search_aria": "Search location",
|
||||||
|
"search_gps": "Use GPS location",
|
||||||
|
"model_automatic_selection": "Automatic selection",
|
||||||
|
"model_selector_aria": "{label} selection",
|
||||||
|
"compare_models_heading": "Models",
|
||||||
|
"compare_models_choose": "Choose which models to plot",
|
||||||
|
"compare_variables_heading": "Hourly Weather Variables",
|
||||||
|
"compare_standard_preset": "Standard comparison",
|
||||||
|
"compare_weather_conditions": "Weather conditions & cloud cover",
|
||||||
|
"compare_wind_direction_height": "Wind direction at {height} m",
|
||||||
|
"compare_standard_preset_description": "A balanced starting point for comparing forecast models.",
|
||||||
|
"compare_preset_active": "Active",
|
||||||
|
"compare_custom_selection": "Custom · {count} selected",
|
||||||
|
"compare_customize_variables": "Customize variables",
|
||||||
|
"compare_restore_defaults": "Restore defaults",
|
||||||
|
"compare_variables_pending": "Variable selection changed",
|
||||||
|
"compare_selected_models": "Selected models",
|
||||||
|
"compare_selection_pending": "Selection changed",
|
||||||
|
"compare_apply_selection": "Apply & reload charts",
|
||||||
|
"compare_discard_changes": "Discard changes",
|
||||||
|
"compare_customize_models": "Customize models",
|
||||||
|
"compare_edit_models": "Edit models",
|
||||||
|
"compare_edit_variables": "Edit variables",
|
||||||
|
"compare_edit_models_description": "Search models or select complete provider families.",
|
||||||
|
"compare_edit_variables_description": "Search variables or select complete thematic groups.",
|
||||||
|
"compare_search_models": "Search models",
|
||||||
|
"compare_search_variables": "Search variables",
|
||||||
|
"compare_show_model_names": "Show model names",
|
||||||
|
"compare_hide_model_names": "Hide model names",
|
||||||
|
"compare_only_selected": "Only selected",
|
||||||
|
"compare_select_group": "Select all",
|
||||||
|
"compare_clear_group": "Clear all",
|
||||||
|
"compare_no_matching_options": "No matching options.",
|
||||||
|
"compare_no_models_selected": "No models selected.",
|
||||||
|
"compare_remove_model": "Remove {model}",
|
||||||
|
"compare_timeline_title": "Model Comparison Timeline",
|
||||||
|
"compare_model_label": "Model",
|
||||||
|
"page_compare_title": "Weather model comparison",
|
||||||
|
"page_compare_description": "Compare hourly forecasts from multiple weather models for any location.",
|
||||||
|
"compare_direction_subtitle": "Direction spread across {count} models · no mean",
|
||||||
|
"compare_scalar_subtitle": "Across {count} models · dashed = model mean",
|
||||||
|
"compare_precipitation_subtitle": "Across {count} models · lower strip = precipitation agreement",
|
||||||
|
"compare_precipitation_agreement": "Agreement",
|
||||||
|
"compare_precipitation_agreement_tooltip": "{wet}/{available} wet · median {median} {unit} · range {min}–{max} {unit}",
|
||||||
|
"compare_model_mean": "Model mean",
|
||||||
|
"compare_showing_previous": "The previous successful comparison remains visible.",
|
||||||
|
"compare_empty_selection": "Select at least one model and one weather variable to build a comparison.",
|
||||||
|
"compare_no_data": "None of the selected models provides usable data for this location and selection.",
|
||||||
|
"compare_weather_codes_only": "Weather conditions and cloud cover are shown in the model timeline below. Select another variable to add a chart.",
|
||||||
|
"compare_model_colors": "Model colors",
|
||||||
|
"compare_group_temperature": "Temperature & humidity",
|
||||||
|
"compare_group_precipitation": "Precipitation & conditions",
|
||||||
|
"compare_group_clouds": "Pressure & clouds",
|
||||||
|
"compare_group_wind": "Wind & atmosphere",
|
||||||
|
"compare_group_upper_air": "Upper-air temperature",
|
||||||
|
"compare_many_models_warning": "{count} models are selected. The comparison may be slower and individual colors harder to distinguish.",
|
||||||
|
"compare_timeline_hint": "Conditions by model; pictogram backgrounds show total cloud cover",
|
||||||
|
"compare_timeline_scroll_aria": "Scrollable model weather timeline",
|
||||||
|
"compare_timeline_caption": "Hourly weather conditions by forecast model",
|
||||||
|
"ensemble_trimmed": "This model's ensemble only reaches about {days} days ahead, the spread is trimmed to its available range.",
|
||||||
|
"seasonal_explainer_before": "A seasonal forecast shows how a whole month is likely to",
|
||||||
|
"seasonal_explainer_strong": "depart from its climate normal",
|
||||||
|
"seasonal_explainer_after": "- not the weather on any given day. Read the monthly trend and the ensemble agreement, not the daily wiggles.",
|
||||||
|
"seasonal_runs_to": "This outlook runs to {date}.",
|
||||||
|
"seasonal_range_aria": "Outlook range",
|
||||||
|
"seasonal_no_normal": "no normal for this month",
|
||||||
|
"normals_loading": "normal loading…",
|
||||||
|
"normals_loading_period": "1991–2020 normal loading…",
|
||||||
|
"normal_band_legend": "normal band = 1991–2020 mean",
|
||||||
|
"anomaly_vs_normal": "{value} vs normal",
|
||||||
|
"stat_avg_temperature": "Average temperature",
|
||||||
|
"stat_mean_temperature": "Mean temperature",
|
||||||
|
"stat_total_precipitation": "Total precipitation",
|
||||||
|
"stat_warmest_day": "Warmest day",
|
||||||
|
"stat_coldest_day": "Coldest day",
|
||||||
|
"historical_daily_heading": "Daily",
|
||||||
|
"historical_daily_hint": "– select a day for hourly detail",
|
||||||
|
"historical_full_range": "– full range",
|
||||||
|
"historical_from": "From",
|
||||||
|
"historical_to": "To",
|
||||||
|
"historical_quick_ranges": "Quick ranges",
|
||||||
|
"historical_last_days": "{days} days",
|
||||||
|
"historical_month_last_year": "Month, last year",
|
||||||
|
"label_day": "day",
|
||||||
|
"label_night": "night",
|
||||||
|
"daycards_past": "Past 3 days",
|
||||||
|
"daycards_load_15": "Load 15 days",
|
||||||
|
"error_technical_details": "Technical details",
|
||||||
|
"err_network_title": "Couldn't reach the weather service",
|
||||||
|
"err_network_hint": "Check your internet connection and try again.",
|
||||||
|
"err_nodata_title": "No data for this location with the selected model",
|
||||||
|
"err_nodata_hint": "Regional weather models only cover their own area - \"Best match\" picks a suitable model automatically.",
|
||||||
|
"err_rejected_title": "The weather service rejected the request",
|
||||||
|
"err_rejected_hint": "Try different settings, or switch the model back to \"Best match\".",
|
||||||
|
"err_generic_title": "Loading the weather data failed",
|
||||||
|
"err_generic_hint": "Try again in a moment. If it keeps happening, switch the model to \"Best match\".",
|
||||||
|
"maps_iframe_title": "Open-Meteo interactive map",
|
||||||
|
"page_maps_title": "Weather map",
|
||||||
|
"page_week_title": "Weather",
|
||||||
|
"search_favorite_add": "Add to favorites",
|
||||||
|
"search_favorite_remove": "Remove from favorites",
|
||||||
|
"supporter_price_per_month": "{amount} / month",
|
||||||
|
"model_best_match_hint": "Automatically picks the best model for this location",
|
||||||
|
"model_updated": "updated {cadence}",
|
||||||
|
"cadence_every_hour": "every hour",
|
||||||
|
"cadence_every_hours": "every {hours} h",
|
||||||
|
"cadence_daily": "daily",
|
||||||
|
"cadence_monthly": "monthly",
|
||||||
|
"cadence_varies": "varies",
|
||||||
|
"model_group_automatic": "Automatic",
|
||||||
|
"model_group_reanalysis": "ECMWF reanalysis",
|
||||||
|
"model_group_regional_reanalysis": "Regional reanalysis",
|
||||||
|
"wmo_0": "Clear sky",
|
||||||
|
"wmo_1": "Mainly clear",
|
||||||
|
"wmo_2": "Partly cloudy",
|
||||||
|
"wmo_3": "Overcast",
|
||||||
|
"wmo_45": "Fog",
|
||||||
|
"wmo_48": "Depositing rime fog",
|
||||||
|
"wmo_51": "Light drizzle",
|
||||||
|
"wmo_53": "Moderate drizzle",
|
||||||
|
"wmo_55": "Dense drizzle",
|
||||||
|
"wmo_56": "Light freezing drizzle",
|
||||||
|
"wmo_57": "Dense freezing drizzle",
|
||||||
|
"wmo_61": "Slight rain",
|
||||||
|
"wmo_63": "Moderate rain",
|
||||||
|
"wmo_65": "Heavy rain",
|
||||||
|
"wmo_66": "Light freezing rain",
|
||||||
|
"wmo_67": "Heavy freezing rain",
|
||||||
|
"wmo_71": "Slight snowfall",
|
||||||
|
"wmo_73": "Moderate snowfall",
|
||||||
|
"wmo_75": "Heavy snowfall",
|
||||||
|
"wmo_77": "Snow grains",
|
||||||
|
"wmo_80": "Slight rain showers",
|
||||||
|
"wmo_81": "Moderate rain showers",
|
||||||
|
"wmo_82": "Violent rain showers",
|
||||||
|
"wmo_85": "Slight snow showers",
|
||||||
|
"wmo_86": "Heavy snow showers",
|
||||||
|
"wmo_95": "Thunderstorm",
|
||||||
|
"wmo_96": "Thunderstorm with slight hail",
|
||||||
|
"wmo_99": "Thunderstorm with heavy hail",
|
||||||
|
"summary_read_more": "Read more",
|
||||||
|
"summary_read_less": "Show less"
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"nav_week": "Pronóstico semanal",
|
||||||
|
"nav_compare": "Comparación de modelos",
|
||||||
|
"nav_14day": "Pronóstico de 14 días",
|
||||||
|
"nav_seasonal": "Estacional",
|
||||||
|
"nav_historical": "Histórico",
|
||||||
|
"nav_maps": "Mapas",
|
||||||
|
"nav_collapse": "Contraer",
|
||||||
|
"nav_expand_sidebar": "Expandir barra lateral",
|
||||||
|
"nav_collapse_sidebar": "Contraer barra lateral",
|
||||||
|
"nav_home": "Inicio de Drizzli",
|
||||||
|
"nav_toggle_menu": "Alternar menú",
|
||||||
|
"search_placeholder": "Buscar ubicación…",
|
||||||
|
"settings_title": "Ajustes",
|
||||||
|
"units_title": "Unidades",
|
||||||
|
"units_aria": "Elegir unidades de medida",
|
||||||
|
"unit_temperature": "Temperatura",
|
||||||
|
"unit_wind_speed": "Velocidad del viento",
|
||||||
|
"unit_precipitation": "Precipitación",
|
||||||
|
"theme_label": "Tema",
|
||||||
|
"theme_system": "Sistema",
|
||||||
|
"theme_light": "Claro",
|
||||||
|
"theme_dark": "Oscuro",
|
||||||
|
"theme_follow_system": "Tema: seguir al sistema",
|
||||||
|
"theme_light_title": "Tema: claro",
|
||||||
|
"theme_dark_title": "Tema: oscuro",
|
||||||
|
"language_label": "Idioma",
|
||||||
|
"supporter_active": "Extras de colaborador activos",
|
||||||
|
"supporter_support": "Apoya Drizz.li",
|
||||||
|
"supporter_manage_key": "Gestiona tu clave de acceso",
|
||||||
|
"supporter_unlock": "Desbloquea los extras de colaborador",
|
||||||
|
"supporter_badge": "Colaborador",
|
||||||
|
"strip_past_label": "antes",
|
||||||
|
"strip_past_aria": "Cargar los últimos 3 días",
|
||||||
|
"strip_days_label": "días",
|
||||||
|
"strip_extend_aria": "Ver el pronóstico completo de 15 días",
|
||||||
|
"strip_history_label": "archivo",
|
||||||
|
"strip_history_aria": "Abrir el archivo meteorológico",
|
||||||
|
"strip_history_title": "Histórico: cualquier fecha desde 1940",
|
||||||
|
"strip_seasonal_label": "estacional",
|
||||||
|
"strip_seasonal_aria": "Abrir la previsión estacional",
|
||||||
|
"strip_seasonal_title": "Previsión estacional: tendencias de los próximos meses",
|
||||||
|
"day_today": "Hoy",
|
||||||
|
"day_tomorrow": "Mañana",
|
||||||
|
"day_yesterday": "Ayer",
|
||||||
|
"page_week_subtitle": "Pronóstico semanal",
|
||||||
|
"page_compare_subtitle": "Comparación de modelos",
|
||||||
|
"page_14day_subtitle": "Pronóstico de conjunto a 14 días",
|
||||||
|
"page_seasonal_subtitle": "Previsión estacional",
|
||||||
|
"page_historical_subtitle": "Clima histórico",
|
||||||
|
"hourly_heading": "por horas",
|
||||||
|
"hourly_variables": "Variables",
|
||||||
|
"meteograms_heading": "Meteogramas",
|
||||||
|
"meteograms_customize": "Personalizar",
|
||||||
|
"meteograms_zoom_hint": "arrastra o",
|
||||||
|
"meteograms_zoom_hint_end": "+ desplazar para ampliar",
|
||||||
|
"range_today": "Hoy",
|
||||||
|
"range_selected_day": "Día seleccionado",
|
||||||
|
"range_3_days": "3 días",
|
||||||
|
"range_5_days": "5 días",
|
||||||
|
"range_all": "Todo",
|
||||||
|
"range_group_aria": "Rango temporal del gráfico",
|
||||||
|
"reset_zoom": "Restablecer zoom",
|
||||||
|
"default_range_title": "Rango temporal predeterminado",
|
||||||
|
"default_range_auto": "Auto",
|
||||||
|
"default_range_auto_hint": "3 días en el móvil, todo en pantallas anchas",
|
||||||
|
"summary_heading": "en palabras",
|
||||||
|
"summary_no_data": "No hay datos horarios para este día.",
|
||||||
|
"label_sunrise": "Amanecer",
|
||||||
|
"label_sunset": "Atardecer",
|
||||||
|
"label_daylight": "Luz diurna",
|
||||||
|
"label_uv_index": "Índice UV",
|
||||||
|
"label_moonrise": "Salida de la luna",
|
||||||
|
"label_moonset": "Puesta de la luna",
|
||||||
|
"label_moon": "Luna",
|
||||||
|
"daylight_hours": "{hours} h {minutes} min",
|
||||||
|
"sunshine_share": "{percent}% de sol",
|
||||||
|
"uv_low": "Bajo",
|
||||||
|
"uv_moderate": "Moderado",
|
||||||
|
"uv_high": "Alto",
|
||||||
|
"uv_very_high": "Muy alto",
|
||||||
|
"uv_extreme": "Extremo",
|
||||||
|
"moon_new": "Luna nueva",
|
||||||
|
"moon_waxing_crescent": "Luna creciente",
|
||||||
|
"moon_first_quarter": "Cuarto creciente",
|
||||||
|
"moon_waxing_gibbous": "Gibosa creciente",
|
||||||
|
"moon_full": "Luna llena",
|
||||||
|
"moon_waning_gibbous": "Gibosa menguante",
|
||||||
|
"moon_last_quarter": "Cuarto menguante",
|
||||||
|
"moon_waning_crescent": "Luna menguante",
|
||||||
|
"cond_clear": "despejado",
|
||||||
|
"cond_fair": "parcialmente nublado",
|
||||||
|
"cond_cloudy": "nublado",
|
||||||
|
"cond_fog": "con niebla",
|
||||||
|
"cond_drizzle": "con llovizna",
|
||||||
|
"cond_rain": "lluvioso",
|
||||||
|
"cond_snow": "con nieve",
|
||||||
|
"cond_thunder": "tormentoso",
|
||||||
|
"period_overnight": "por la noche",
|
||||||
|
"period_morning": "por la mañana",
|
||||||
|
"period_afternoon": "por la tarde",
|
||||||
|
"period_evening": "al anochecer",
|
||||||
|
"footer_tagline": "Pronósticos rápidos y directos basados en datos abiertos.",
|
||||||
|
"footer_data_by": "Datos meteorológicos de",
|
||||||
|
"footer_forecasts": "Pronósticos",
|
||||||
|
"footer_popular": "Ubicaciones populares",
|
||||||
|
"footer_about_section": "Acerca de",
|
||||||
|
"model_weather": "Modelo meteorológico",
|
||||||
|
"model_ensemble": "Modelo de conjunto",
|
||||||
|
"sky_all_1": "{condition} todo el día.",
|
||||||
|
"sky_all_2": "Un día {condition} de principio a fin.",
|
||||||
|
"sky_all_3": "Se mantiene {condition} durante toda la jornada.",
|
||||||
|
"sky_two_1": "{c1} {p1} y {c2} {p2}.",
|
||||||
|
"sky_two_2": "La jornada empieza {c1} {p1} antes de volverse {c2} {p2}.",
|
||||||
|
"sky_two_3": "Se espera cielo {c1} {p1} y {c2} {p2}.",
|
||||||
|
"sky_three_1": "{c1} {p1}, {c2} {p2} y {c3} {p3}.",
|
||||||
|
"sky_three_2": "Empieza {c1} {p1}, se vuelve {c2} {p2} y acaba {c3} {p3}.",
|
||||||
|
"sky_three_3": "De cielo {c1} {p1} a {c2} {p2}, para quedarse {c3} {p3}.",
|
||||||
|
"temp_1": "Máximas de {high} y mínimas de {low} por la noche.",
|
||||||
|
"temp_2": "Las temperaturas suben hasta {high} y bajan a {low} tras el anochecer.",
|
||||||
|
"temp_3": "Entre {low} y {high} a lo largo del día.",
|
||||||
|
"temp_feels_1": "Máximas de {high} y mínimas de {low}, aunque la sensación será de {feels}.",
|
||||||
|
"temp_feels_2": "Hasta {high} en el termómetro, aunque se sentirán {feels}, antes de bajar a {low}.",
|
||||||
|
"temp_feels_3": "Alcanza {high} pero se sentirá como {feels}, con descenso a {low} más tarde.",
|
||||||
|
"precip_window_1": "Unos {amount} de precipitación, sobre todo {when}.",
|
||||||
|
"precip_window_2": "Se esperan unos {amount}, concentrados {when}.",
|
||||||
|
"precip_window_3": "El tramo más húmedo será {when}, con un total aproximado de {amount}.",
|
||||||
|
"precip_spread_1": "Unos {amount} de precipitación repartidos a lo largo del día.",
|
||||||
|
"precip_spread_2": "Aproximadamente {amount} caen a intervalos durante el día.",
|
||||||
|
"precip_spread_3": "Chubascos intermitentes que suman unos {amount}.",
|
||||||
|
"precip_chance_1": "Mayormente seco, con hasta un {percent}% de probabilidad de chubascos.",
|
||||||
|
"precip_chance_2": "Apenas se espera lluvia, aunque hay un {percent}% de probabilidad de un chubasco pasajero.",
|
||||||
|
"precip_chance_3": "Un {percent}% de riesgo de chubascos, pero nada que exija paraguas.",
|
||||||
|
"precip_dry_1": "Se mantendrá seco todo el día.",
|
||||||
|
"precip_dry_2": "No se espera ni una gota.",
|
||||||
|
"precip_dry_3": "Seco de principio a fin.",
|
||||||
|
"wind_dir_1": "Viento del {direction} de hasta {speed}.",
|
||||||
|
"wind_dir_2": "La brisa del {direction} alcanza los {speed}.",
|
||||||
|
"wind_dir_3": "El viento sopla del {direction}, con picos cercanos a {speed}.",
|
||||||
|
"wind_dir_gusts_1": "Viento del {direction} de hasta {speed}, con rachas de {gust}.",
|
||||||
|
"wind_dir_gusts_2": "Un viento del {direction} de {speed} con rachas de hasta {gust}.",
|
||||||
|
"wind_dir_gusts_3": "Se esperan {speed} del {direction}, con rachas puntuales de {gust}.",
|
||||||
|
"wind_1": "Viento de hasta {speed}.",
|
||||||
|
"wind_2": "La brisa alcanza unos {speed}.",
|
||||||
|
"wind_3": "Aire en movimiento de hasta {speed}.",
|
||||||
|
"wind_gusts_1": "Viento de hasta {speed}, con rachas de {gust}.",
|
||||||
|
"wind_gusts_2": "Hasta {speed}, con rachas que llegan a {gust}.",
|
||||||
|
"wind_gusts_3": "Un día racheado: {speed} sostenidos y {gust} en los picos.",
|
||||||
|
"calm_1": "Apenas hay viento.",
|
||||||
|
"calm_2": "El aire se mantiene casi en calma.",
|
||||||
|
"calm_3": "Prácticamente sin viento en todo el día.",
|
||||||
|
"uv_1": "El índice UV llega a {value} ({label}), así que protégete al mediodía.",
|
||||||
|
"uv_2": "El sol aprieta al mediodía: UV {value}, {label}.",
|
||||||
|
"uv_3": "Conviene buscar sombra al mediodía: el UV alcanza {value} ({label}).",
|
||||||
|
"legal_about": "Acerca de",
|
||||||
|
"legal_imprint": "Aviso legal",
|
||||||
|
"legal_privacy": "Privacidad",
|
||||||
|
"legal_terms": "Condiciones",
|
||||||
|
"legal_nav": "Legal",
|
||||||
|
"city_weather": "El tiempo en {city}",
|
||||||
|
"var_temperature": "Temperatura",
|
||||||
|
"var_temperature_short": "Temp",
|
||||||
|
"var_icons": "Iconos",
|
||||||
|
"var_icons_short": "Iconos",
|
||||||
|
"var_apparent": "Sensación térmica",
|
||||||
|
"var_apparent_short": "Sensación",
|
||||||
|
"var_dew_point": "Punto de rocío",
|
||||||
|
"var_dew_point_short": "Rocío",
|
||||||
|
"var_cloud": "Nubosidad",
|
||||||
|
"var_cloud_short": "Nubes",
|
||||||
|
"var_cloud_low": "Nubosidad baja",
|
||||||
|
"var_cloud_low_short": "Baja",
|
||||||
|
"var_cloud_mid": "Nubosidad media",
|
||||||
|
"var_cloud_mid_short": "Media",
|
||||||
|
"var_cloud_high": "Nubosidad alta",
|
||||||
|
"var_cloud_high_short": "Alta",
|
||||||
|
"var_precipitation": "Precipitación",
|
||||||
|
"var_precipitation_short": "Precip.",
|
||||||
|
"var_pop": "Prob. de precipitación",
|
||||||
|
"var_pop_short": "Prob.",
|
||||||
|
"var_rain": "Lluvia",
|
||||||
|
"var_rain_short": "Lluvia",
|
||||||
|
"var_showers": "Chubascos",
|
||||||
|
"var_showers_short": "Chub.",
|
||||||
|
"var_snowfall": "Nevadas",
|
||||||
|
"var_snowfall_short": "Nieve",
|
||||||
|
"var_wind": "Velocidad del viento",
|
||||||
|
"var_wind_short": "Viento",
|
||||||
|
"var_wind_dir": "Dirección del viento",
|
||||||
|
"var_wind_dir_short": "Dir.",
|
||||||
|
"var_gusts": "Rachas de viento",
|
||||||
|
"var_gusts_short": "Rachas",
|
||||||
|
"var_humidity": "Humedad",
|
||||||
|
"var_humidity_short": "HR",
|
||||||
|
"var_pressure": "Presión (MSL)",
|
||||||
|
"var_pressure_short": "MSLP",
|
||||||
|
"var_surface_pressure": "Presión en superficie",
|
||||||
|
"var_surface_pressure_short": "Psfc",
|
||||||
|
"var_uv": "Índice UV",
|
||||||
|
"var_uv_short": "UV",
|
||||||
|
"var_visibility": "Visibilidad",
|
||||||
|
"var_visibility_short": "Vis.",
|
||||||
|
"var_cape": "CAPE",
|
||||||
|
"var_cape_short": "CAPE",
|
||||||
|
"var_time": "Hora",
|
||||||
|
"no_data_title": "Este modelo no tiene datos aquí",
|
||||||
|
"no_data_body": "El modelo seleccionado no cubre {location}: los modelos regionales solo ofrecen datos dentro de su propia área.",
|
||||||
|
"no_data_try_city": "Cambiar la ubicación a {city}",
|
||||||
|
"no_data_best_match": "Cambiar a «Best match»",
|
||||||
|
"model_archive": "Reanálisis",
|
||||||
|
"model_seasonal": "Modelo estacional",
|
||||||
|
"nearby_cities_title": "Ciudades cercanas",
|
||||||
|
"nearby_cities_subtitle": "Máximas y mínimas del día seleccionado",
|
||||||
|
"supporter_dialog_title": "Colaborador de Drizz.li",
|
||||||
|
"supporter_dialog_desc": "Pega la clave de acceso de tu correo de colaborador para desbloquear los extras.",
|
||||||
|
"supporter_extras_active": "Los extras de colaborador están activos",
|
||||||
|
"supporter_lifetime": "Acceso de por vida",
|
||||||
|
"supporter_active_until": "Activo hasta el {date}",
|
||||||
|
"supporter_access_key": "Clave de acceso",
|
||||||
|
"supporter_key_invalid": "Esa clave no es válida o ha caducado.",
|
||||||
|
"supporter_server_unreachable": "No se pudo conectar con el servidor. Comprueba tu conexión e inténtalo de nuevo.",
|
||||||
|
"supporter_verifying": "Verificando…",
|
||||||
|
"supporter_unlock_button": "Desbloquear",
|
||||||
|
"supporter_remove_key": "Quitar la clave de este dispositivo",
|
||||||
|
"supporter_no_key_yet": "¿Aún no tienes clave?",
|
||||||
|
"supporter_support_from": "Apoya el proyecto desde {price}",
|
||||||
|
"supporter_checking": "Comprobando tu suscripción…",
|
||||||
|
"supporter_gate_title": "{feature} es un extra para colaboradores",
|
||||||
|
"supporter_gate_body": "Drizz.li es gratuito y de código abierto. Aporta desde {price} para mantenerlo en marcha; como agradecimiento, los colaboradores desbloquean los extras.",
|
||||||
|
"supporter_become": "Hazte colaborador",
|
||||||
|
"supporter_have_key": "Ya tengo una clave",
|
||||||
|
"supporter_key_expired": "Tu clave guardada ya no es válida o ha caducado.",
|
||||||
|
"supporter_enter_key": "Introduce tu clave de acceso.",
|
||||||
|
"supporter_perk_historical": "Clima histórico y comparación con las normales climáticas",
|
||||||
|
"supporter_perk_seasonal": "Perspectiva estacional: los próximos meses frente a la normal climática",
|
||||||
|
"supporter_perk_future": "Nuevos extras para colaboradores en cuanto lleguen",
|
||||||
|
"action_close": "Cerrar",
|
||||||
|
"action_done": "Listo",
|
||||||
|
"action_try_again": "Reintentar",
|
||||||
|
"action_reset_defaults": "Restablecer valores por defecto",
|
||||||
|
"customize_meteograms": "Personalizar meteogramas",
|
||||||
|
"customizer_intro": "Arrastra variables entre gráficos para crear tu propio diseño.",
|
||||||
|
"customizer_chart_n": "Gráfico {number}",
|
||||||
|
"customizer_delete_chart": "Eliminar el gráfico {number}",
|
||||||
|
"customizer_add_chart": "+ Añadir gráfico",
|
||||||
|
"customizer_available": "Variables disponibles",
|
||||||
|
"customizer_drag": "Arrastrar {variable}",
|
||||||
|
"customizer_remove": "Quitar {variable}",
|
||||||
|
"customizer_drop_here": "Suelta variables aquí",
|
||||||
|
"customizer_all_in_use": "Todas las variables están en uso",
|
||||||
|
"variables_aria": "Selección de variables",
|
||||||
|
"variables_close": "Cerrar la selección de variables",
|
||||||
|
"variables_table_section": "Tabla horaria",
|
||||||
|
"variables_move_up": "Subir {variable}",
|
||||||
|
"variables_move_down": "Bajar {variable}",
|
||||||
|
"variables_charts_hint_before": "Las variables del meteograma se configuran con el botón",
|
||||||
|
"variables_charts_hint_after": "situado encima de los gráficos.",
|
||||||
|
"table_customize": "Personalizar variables",
|
||||||
|
"table_interval_aria": "Intervalo horario",
|
||||||
|
"table_now": "Ahora",
|
||||||
|
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
|
||||||
|
"page_loading": "Cargando…",
|
||||||
|
"page_loading_dismiss": "Descartar",
|
||||||
|
"charts_loading": "Cargando gráficos…",
|
||||||
|
"chart_download": "Descargar el meteograma como imagen PNG",
|
||||||
|
"chart_credit_viz": "visualización de",
|
||||||
|
"chart_toggle_series": "Mostrar u ocultar {series}",
|
||||||
|
"legend_show": "Mostrar leyenda",
|
||||||
|
"meteograms_none_before": "No hay meteogramas configurados,",
|
||||||
|
"meteograms_none_action": "añade algunas variables",
|
||||||
|
"meteograms_none_historical": "No hay meteogramas configurados. Añade variables desde la página del pronóstico semanal.",
|
||||||
|
"search_searching": "Buscando…",
|
||||||
|
"search_favorites": "Favoritos",
|
||||||
|
"search_recent": "Recientes",
|
||||||
|
"search_hint": "Empieza a escribir o usa el GPS para detectar tu posición",
|
||||||
|
"search_no_results": "No se encontraron ubicaciones",
|
||||||
|
"search_remove_recent": "Quitar {location} de las ubicaciones recientes",
|
||||||
|
"search_remove_recent_short": "Quitar de recientes",
|
||||||
|
"search_aria": "Buscar ubicación",
|
||||||
|
"search_gps": "Usar la ubicación GPS",
|
||||||
|
"model_automatic_selection": "Selección automática",
|
||||||
|
"model_selector_aria": "Selección de {label}",
|
||||||
|
"compare_models_heading": "Modelos",
|
||||||
|
"compare_models_choose": "Elige los modelos a comparar",
|
||||||
|
"compare_variables_heading": "Variables meteorológicas horarias",
|
||||||
|
"compare_standard_preset": "Comparación estándar",
|
||||||
|
"compare_weather_conditions": "Condiciones y nubosidad",
|
||||||
|
"compare_wind_direction_height": "Dirección del viento a {height} m",
|
||||||
|
"compare_standard_preset_description": "Un punto de partida equilibrado para comparar modelos de previsión.",
|
||||||
|
"compare_preset_active": "Activa",
|
||||||
|
"compare_custom_selection": "Personalizada · {count} seleccionadas",
|
||||||
|
"compare_customize_variables": "Personalizar variables",
|
||||||
|
"compare_restore_defaults": "Restablecer selección estándar",
|
||||||
|
"compare_variables_pending": "Selección de variables modificada",
|
||||||
|
"compare_selected_models": "Modelos seleccionados",
|
||||||
|
"compare_selection_pending": "Selección modificada",
|
||||||
|
"compare_apply_selection": "Aplicar y recargar gráficos",
|
||||||
|
"compare_discard_changes": "Descartar cambios",
|
||||||
|
"compare_customize_models": "Personalizar modelos",
|
||||||
|
"compare_edit_models": "Editar modelos",
|
||||||
|
"compare_edit_variables": "Editar variables",
|
||||||
|
"compare_edit_models_description": "Busca modelos o selecciona familias completas de proveedores.",
|
||||||
|
"compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.",
|
||||||
|
"compare_search_models": "Buscar modelos",
|
||||||
|
"compare_search_variables": "Buscar variables",
|
||||||
|
"compare_show_model_names": "Mostrar nombres de modelos",
|
||||||
|
"compare_hide_model_names": "Ocultar nombres de modelos",
|
||||||
|
"compare_only_selected": "Solo seleccionados",
|
||||||
|
"compare_select_group": "Seleccionar todo",
|
||||||
|
"compare_clear_group": "Deseleccionar todo",
|
||||||
|
"compare_no_matching_options": "No hay opciones coincidentes.",
|
||||||
|
"compare_no_models_selected": "No hay modelos seleccionados.",
|
||||||
|
"compare_remove_model": "Quitar {model}",
|
||||||
|
"compare_timeline_title": "Cronología de comparación de modelos",
|
||||||
|
"compare_model_label": "Modelo",
|
||||||
|
"page_compare_title": "Comparación de modelos meteorológicos",
|
||||||
|
"page_compare_description": "Compara previsiones horarias de varios modelos meteorológicos para cualquier lugar.",
|
||||||
|
"compare_direction_subtitle": "Dispersión de dirección entre {count} modelos · sin media",
|
||||||
|
"compare_scalar_subtitle": "Entre {count} modelos · discontinuo = media de modelos",
|
||||||
|
"compare_precipitation_subtitle": "Entre {count} modelos · franja inferior = concordancia de precipitación",
|
||||||
|
"compare_precipitation_agreement": "Concordancia",
|
||||||
|
"compare_precipitation_agreement_tooltip": "{wet}/{available} con precipitación · mediana {median} {unit} · rango {min}–{max} {unit}",
|
||||||
|
"compare_model_mean": "Media de modelos",
|
||||||
|
"compare_showing_previous": "La comparación anterior sigue visible.",
|
||||||
|
"compare_empty_selection": "Selecciona al menos un modelo y una variable meteorológica.",
|
||||||
|
"compare_no_data": "Ninguno de los modelos seleccionados ofrece datos útiles para este lugar y selección.",
|
||||||
|
"compare_weather_codes_only": "Las condiciones y la nubosidad aparecen en la cronología inferior. Selecciona otra variable para añadir un gráfico.",
|
||||||
|
"compare_model_colors": "Colores de los modelos",
|
||||||
|
"compare_group_temperature": "Temperatura y humedad",
|
||||||
|
"compare_group_precipitation": "Precipitación y condiciones",
|
||||||
|
"compare_group_clouds": "Presión y nubes",
|
||||||
|
"compare_group_wind": "Viento y atmósfera",
|
||||||
|
"compare_group_upper_air": "Temperatura en altura",
|
||||||
|
"compare_many_models_warning": "Hay {count} modelos seleccionados. La comparación puede ser más lenta y los colores más difíciles de distinguir.",
|
||||||
|
"compare_timeline_hint": "Condiciones por modelo; el fondo de los pictogramas indica la nubosidad total",
|
||||||
|
"compare_timeline_scroll_aria": "Cronología meteorológica desplazable por modelos",
|
||||||
|
"compare_timeline_caption": "Condiciones meteorológicas horarias por modelo de previsión",
|
||||||
|
"ensemble_trimmed": "El ensemble de este modelo solo llega unos {days} días, así que la dispersión se recorta a su rango disponible.",
|
||||||
|
"seasonal_explainer_before": "Una previsión estacional muestra cuánto es probable que todo un mes",
|
||||||
|
"seasonal_explainer_strong": "se desvíe de su normal climática",
|
||||||
|
"seasonal_explainer_after": "- no el tiempo de un día concreto. Fíjate en la tendencia mensual y en el acuerdo del ensemble, no en los vaivenes diarios.",
|
||||||
|
"seasonal_runs_to": "Esta perspectiva llega hasta el {date}.",
|
||||||
|
"seasonal_range_aria": "Periodo de la perspectiva",
|
||||||
|
"seasonal_no_normal": "sin normal para este mes",
|
||||||
|
"normals_loading": "cargando la normal…",
|
||||||
|
"normals_loading_period": "cargando la normal 1991–2020…",
|
||||||
|
"normal_band_legend": "banda normal = media 1991–2020",
|
||||||
|
"anomaly_vs_normal": "{value} respecto a la normal",
|
||||||
|
"stat_avg_temperature": "Temperatura media",
|
||||||
|
"stat_mean_temperature": "Temperatura media",
|
||||||
|
"stat_total_precipitation": "Precipitación total",
|
||||||
|
"stat_warmest_day": "Día más cálido",
|
||||||
|
"stat_coldest_day": "Día más frío",
|
||||||
|
"historical_daily_heading": "Diario",
|
||||||
|
"historical_daily_hint": "– selecciona un día para el detalle horario",
|
||||||
|
"historical_full_range": "– rango completo",
|
||||||
|
"historical_from": "Desde",
|
||||||
|
"historical_to": "Hasta",
|
||||||
|
"historical_quick_ranges": "Rangos rápidos",
|
||||||
|
"historical_last_days": "{days} días",
|
||||||
|
"historical_month_last_year": "Este mes, el año pasado",
|
||||||
|
"label_day": "día",
|
||||||
|
"label_night": "noche",
|
||||||
|
"daycards_past": "3 días pasados",
|
||||||
|
"daycards_load_15": "Cargar 15 días",
|
||||||
|
"error_technical_details": "Detalles técnicos",
|
||||||
|
"err_network_title": "No se pudo conectar con el servicio meteorológico",
|
||||||
|
"err_network_hint": "Comprueba tu conexión a internet e inténtalo de nuevo.",
|
||||||
|
"err_nodata_title": "No hay datos para esta ubicación con el modelo seleccionado",
|
||||||
|
"err_nodata_hint": "Los modelos regionales solo cubren su propia área - \"Best match\" elige automáticamente un modelo adecuado.",
|
||||||
|
"err_rejected_title": "El servicio meteorológico rechazó la solicitud",
|
||||||
|
"err_rejected_hint": "Prueba otros ajustes o vuelve al modelo \"Best match\".",
|
||||||
|
"err_generic_title": "No se pudieron cargar los datos meteorológicos",
|
||||||
|
"err_generic_hint": "Vuelve a intentarlo en un momento. Si sigue ocurriendo, cambia el modelo a \"Best match\".",
|
||||||
|
"maps_iframe_title": "Mapa interactivo de Open-Meteo",
|
||||||
|
"page_maps_title": "Mapa meteorológico",
|
||||||
|
"page_week_title": "El tiempo",
|
||||||
|
"search_favorite_add": "Añadir a favoritos",
|
||||||
|
"search_favorite_remove": "Quitar de favoritos",
|
||||||
|
"supporter_price_per_month": "{amount} / mes",
|
||||||
|
"model_best_match_hint": "Elige automáticamente el mejor modelo para esta ubicación",
|
||||||
|
"model_updated": "actualizado {cadence}",
|
||||||
|
"cadence_every_hour": "cada hora",
|
||||||
|
"cadence_every_hours": "cada {hours} h",
|
||||||
|
"cadence_daily": "a diario",
|
||||||
|
"cadence_monthly": "cada mes",
|
||||||
|
"cadence_varies": "variable",
|
||||||
|
"model_group_automatic": "Automático",
|
||||||
|
"model_group_reanalysis": "Reanálisis del ECMWF",
|
||||||
|
"model_group_regional_reanalysis": "Reanálisis regional",
|
||||||
|
"wmo_0": "Cielo despejado",
|
||||||
|
"wmo_1": "Mayormente despejado",
|
||||||
|
"wmo_2": "Parcialmente nublado",
|
||||||
|
"wmo_3": "Cubierto",
|
||||||
|
"wmo_45": "Niebla",
|
||||||
|
"wmo_48": "Niebla engelante",
|
||||||
|
"wmo_51": "Llovizna débil",
|
||||||
|
"wmo_53": "Llovizna moderada",
|
||||||
|
"wmo_55": "Llovizna intensa",
|
||||||
|
"wmo_56": "Llovizna engelante débil",
|
||||||
|
"wmo_57": "Llovizna engelante intensa",
|
||||||
|
"wmo_61": "Lluvia débil",
|
||||||
|
"wmo_63": "Lluvia moderada",
|
||||||
|
"wmo_65": "Lluvia intensa",
|
||||||
|
"wmo_66": "Lluvia engelante débil",
|
||||||
|
"wmo_67": "Lluvia engelante intensa",
|
||||||
|
"wmo_71": "Nevada débil",
|
||||||
|
"wmo_73": "Nevada moderada",
|
||||||
|
"wmo_75": "Nevada intensa",
|
||||||
|
"wmo_77": "Granos de nieve",
|
||||||
|
"wmo_80": "Chubascos débiles",
|
||||||
|
"wmo_81": "Chubascos moderados",
|
||||||
|
"wmo_82": "Chubascos violentos",
|
||||||
|
"wmo_85": "Chubascos de nieve débiles",
|
||||||
|
"wmo_86": "Chubascos de nieve intensos",
|
||||||
|
"wmo_95": "Tormenta",
|
||||||
|
"wmo_96": "Tormenta con granizo ligero",
|
||||||
|
"wmo_99": "Tormenta con granizo fuerte",
|
||||||
|
"summary_read_more": "Leer más",
|
||||||
|
"summary_read_less": "Mostrar menos"
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"nav_week": "Prévisions hebdomadaires",
|
||||||
|
"nav_compare": "Comparaison des modèles",
|
||||||
|
"nav_14day": "Prévisions à 14 jours",
|
||||||
|
"nav_seasonal": "Saisonnier",
|
||||||
|
"nav_historical": "Historique",
|
||||||
|
"nav_maps": "Cartes",
|
||||||
|
"nav_collapse": "Réduire",
|
||||||
|
"nav_expand_sidebar": "Déployer la barre latérale",
|
||||||
|
"nav_collapse_sidebar": "Réduire la barre latérale",
|
||||||
|
"nav_home": "Accueil Drizzli",
|
||||||
|
"nav_toggle_menu": "Basculer le menu",
|
||||||
|
"search_placeholder": "Rechercher un lieu…",
|
||||||
|
"settings_title": "Paramètres",
|
||||||
|
"units_title": "Unités",
|
||||||
|
"units_aria": "Choisir les unités de mesure",
|
||||||
|
"unit_temperature": "Température",
|
||||||
|
"unit_wind_speed": "Vitesse du vent",
|
||||||
|
"unit_precipitation": "Précipitations",
|
||||||
|
"theme_label": "Thème",
|
||||||
|
"theme_system": "Système",
|
||||||
|
"theme_light": "Clair",
|
||||||
|
"theme_dark": "Sombre",
|
||||||
|
"theme_follow_system": "Thème : suivre le système",
|
||||||
|
"theme_light_title": "Thème : clair",
|
||||||
|
"theme_dark_title": "Thème : sombre",
|
||||||
|
"language_label": "Langue",
|
||||||
|
"supporter_active": "Bonus contributeur actifs",
|
||||||
|
"supporter_support": "Soutenir Drizz.li",
|
||||||
|
"supporter_manage_key": "Gérer votre clé d'accès",
|
||||||
|
"supporter_unlock": "Débloquer les bonus contributeur",
|
||||||
|
"supporter_badge": "Contributeur",
|
||||||
|
"strip_past_label": "avant",
|
||||||
|
"strip_past_aria": "Charger les 3 derniers jours",
|
||||||
|
"strip_days_label": "jours",
|
||||||
|
"strip_extend_aria": "Afficher les 15 jours complets",
|
||||||
|
"strip_history_label": "archives",
|
||||||
|
"strip_history_aria": "Ouvrir les archives météo",
|
||||||
|
"strip_history_title": "Historique : toute date depuis 1940",
|
||||||
|
"strip_seasonal_label": "saisonnier",
|
||||||
|
"strip_seasonal_aria": "Ouvrir les tendances saisonnières",
|
||||||
|
"strip_seasonal_title": "Tendances saisonnières : les mois à venir",
|
||||||
|
"day_today": "Aujourd'hui",
|
||||||
|
"day_tomorrow": "Demain",
|
||||||
|
"day_yesterday": "Hier",
|
||||||
|
"page_week_subtitle": "Prévisions hebdomadaires",
|
||||||
|
"page_compare_subtitle": "Comparaison des modèles",
|
||||||
|
"page_14day_subtitle": "Prévision d'ensemble à 14 jours",
|
||||||
|
"page_seasonal_subtitle": "Tendances saisonnières",
|
||||||
|
"page_historical_subtitle": "Météo historique",
|
||||||
|
"hourly_heading": "par heure",
|
||||||
|
"hourly_variables": "Variables",
|
||||||
|
"meteograms_heading": "Météogrammes",
|
||||||
|
"meteograms_customize": "Personnaliser",
|
||||||
|
"meteograms_zoom_hint": "glissez ou",
|
||||||
|
"meteograms_zoom_hint_end": "+ défiler pour zoomer",
|
||||||
|
"range_today": "Aujourd'hui",
|
||||||
|
"range_selected_day": "Jour sélectionné",
|
||||||
|
"range_3_days": "3 jours",
|
||||||
|
"range_5_days": "5 jours",
|
||||||
|
"range_all": "Tout",
|
||||||
|
"range_group_aria": "Plage horaire du graphique",
|
||||||
|
"reset_zoom": "Réinitialiser le zoom",
|
||||||
|
"default_range_title": "Plage horaire par défaut",
|
||||||
|
"default_range_auto": "Auto",
|
||||||
|
"default_range_auto_hint": "3 jours sur mobile, tout sur grand écran",
|
||||||
|
"summary_heading": "en résumé",
|
||||||
|
"summary_no_data": "Aucun détail horaire pour ce jour.",
|
||||||
|
"label_sunrise": "Lever du soleil",
|
||||||
|
"label_sunset": "Coucher du soleil",
|
||||||
|
"label_daylight": "Durée du jour",
|
||||||
|
"label_uv_index": "Indice UV",
|
||||||
|
"label_moonrise": "Lever de la lune",
|
||||||
|
"label_moonset": "Coucher de la lune",
|
||||||
|
"label_moon": "Lune",
|
||||||
|
"daylight_hours": "{hours} h {minutes} min",
|
||||||
|
"sunshine_share": "{percent}% de soleil",
|
||||||
|
"uv_low": "Faible",
|
||||||
|
"uv_moderate": "Modéré",
|
||||||
|
"uv_high": "Élevé",
|
||||||
|
"uv_very_high": "Très élevé",
|
||||||
|
"uv_extreme": "Extrême",
|
||||||
|
"moon_new": "Nouvelle lune",
|
||||||
|
"moon_waxing_crescent": "Premier croissant",
|
||||||
|
"moon_first_quarter": "Premier quartier",
|
||||||
|
"moon_waxing_gibbous": "Gibbeuse croissante",
|
||||||
|
"moon_full": "Pleine lune",
|
||||||
|
"moon_waning_gibbous": "Gibbeuse décroissante",
|
||||||
|
"moon_last_quarter": "Dernier quartier",
|
||||||
|
"moon_waning_crescent": "Dernier croissant",
|
||||||
|
"cond_clear": "dégagé",
|
||||||
|
"cond_fair": "partiellement nuageux",
|
||||||
|
"cond_cloudy": "couvert",
|
||||||
|
"cond_fog": "brumeux",
|
||||||
|
"cond_drizzle": "bruineux",
|
||||||
|
"cond_rain": "pluvieux",
|
||||||
|
"cond_snow": "neigeux",
|
||||||
|
"cond_thunder": "orageux",
|
||||||
|
"period_overnight": "la nuit",
|
||||||
|
"period_morning": "le matin",
|
||||||
|
"period_afternoon": "l'après-midi",
|
||||||
|
"period_evening": "en soirée",
|
||||||
|
"footer_tagline": "Des prévisions météo rapides et sans fioritures, basées sur des données ouvertes.",
|
||||||
|
"footer_data_by": "Données météo par",
|
||||||
|
"footer_forecasts": "Prévisions",
|
||||||
|
"footer_popular": "Lieux populaires",
|
||||||
|
"footer_about_section": "À propos",
|
||||||
|
"model_weather": "Modèle météo",
|
||||||
|
"model_ensemble": "Modèle d'ensemble",
|
||||||
|
"sky_all_1": "{condition} toute la journée.",
|
||||||
|
"sky_all_2": "Une journée {condition} du matin au soir.",
|
||||||
|
"sky_all_3": "Le ciel reste {condition} tout du long.",
|
||||||
|
"sky_two_1": "{c1} {p1}, puis {c2} {p2}.",
|
||||||
|
"sky_two_2": "La journée débute {c1} {p1} avant de devenir {c2} {p2}.",
|
||||||
|
"sky_two_3": "Ciel {c1} {p1}, puis {c2} {p2}.",
|
||||||
|
"sky_three_1": "{c1} {p1}, puis {c2} {p2} et enfin {c3} {p3}.",
|
||||||
|
"sky_three_2": "Le ciel est {c1} {p1}, devient {c2} {p2} et finit {c3} {p3}.",
|
||||||
|
"sky_three_3": "D'un ciel {c1} {p1} à {c2} {p2}, avant de rester {c3} {p3}.",
|
||||||
|
"temp_1": "Maximales proches de {high}, minimales de {low} la nuit.",
|
||||||
|
"temp_2": "Les températures montent à {high} et retombent à {low} après la tombée de la nuit.",
|
||||||
|
"temp_3": "Entre {low} et {high} au fil de la journée.",
|
||||||
|
"temp_feels_1": "Maximales proches de {high}, minimales de {low}, mais ressenti plutôt {feels}.",
|
||||||
|
"temp_feels_2": "Jusqu'à {high} au thermomètre, ressenti {feels}, avant de redescendre à {low}.",
|
||||||
|
"temp_feels_3": "Il fera {high} mais on ressentira {feels}, avec {low} plus tard.",
|
||||||
|
"precip_window_1": "Environ {amount} de précipitations, surtout {when}.",
|
||||||
|
"precip_window_2": "Comptez environ {amount}, concentrés {when}.",
|
||||||
|
"precip_window_3": "Le passage le plus humide se situe {when}, pour environ {amount} au total.",
|
||||||
|
"precip_spread_1": "Environ {amount} de précipitations réparties sur la journée.",
|
||||||
|
"precip_spread_2": "Environ {amount} tombent par intermittence au cours de la journée.",
|
||||||
|
"precip_spread_3": "Des averses vont et viennent, pour un total d'environ {amount}.",
|
||||||
|
"precip_chance_1": "Temps plutôt sec, avec jusqu'à {percent}% de risque d'averse.",
|
||||||
|
"precip_chance_2": "Peu de pluie attendue, mais {percent}% de risque d'une averse passagère.",
|
||||||
|
"precip_chance_3": "{percent}% de risque d'averse, rien qui justifie un parapluie.",
|
||||||
|
"precip_dry_1": "Le temps reste sec toute la journée.",
|
||||||
|
"precip_dry_2": "Pas une goutte attendue.",
|
||||||
|
"precip_dry_3": "Sec du début à la fin.",
|
||||||
|
"wind_dir_1": "Vent de secteur {direction} atteignant {speed}.",
|
||||||
|
"wind_dir_2": "Une brise de secteur {direction} culmine à {speed}.",
|
||||||
|
"wind_dir_3": "Le vent s'oriente au secteur {direction}, avec des pointes proches de {speed}.",
|
||||||
|
"wind_dir_gusts_1": "Vent de secteur {direction} atteignant {speed}, avec des rafales à {gust}.",
|
||||||
|
"wind_dir_gusts_2": "Un vent de secteur {direction} de {speed}, avec des rafales jusqu'à {gust}.",
|
||||||
|
"wind_dir_gusts_3": "Attendez-vous à {speed} de secteur {direction}, avec des rafales de {gust} par moments.",
|
||||||
|
"wind_1": "Vent atteignant {speed}.",
|
||||||
|
"wind_2": "La brise culmine autour de {speed}.",
|
||||||
|
"wind_3": "Un vent allant jusqu'à {speed}.",
|
||||||
|
"wind_gusts_1": "Vent atteignant {speed}, avec des rafales à {gust}.",
|
||||||
|
"wind_gusts_2": "Jusqu'à {speed}, avec des rafales atteignant {gust}.",
|
||||||
|
"wind_gusts_3": "Une journée à rafales : {speed} en continu, {gust} en pointe.",
|
||||||
|
"calm_1": "Pratiquement pas de vent.",
|
||||||
|
"calm_2": "L'air reste quasiment immobile.",
|
||||||
|
"calm_3": "Quasiment aucun vent de la journée.",
|
||||||
|
"uv_1": "Indice UV jusqu'à {value} ({label}) : protégez-vous en milieu de journée.",
|
||||||
|
"uv_2": "Le soleil tape à la mi-journée : UV {value}, {label}.",
|
||||||
|
"uv_3": "Mieux vaut chercher l'ombre à midi : l'UV atteint {value} ({label}).",
|
||||||
|
"legal_about": "À propos",
|
||||||
|
"legal_imprint": "Mentions légales",
|
||||||
|
"legal_privacy": "Confidentialité",
|
||||||
|
"legal_terms": "Conditions",
|
||||||
|
"legal_nav": "Informations légales",
|
||||||
|
"city_weather": "Météo à {city}",
|
||||||
|
"var_temperature": "Température",
|
||||||
|
"var_temperature_short": "Temp",
|
||||||
|
"var_icons": "Icônes météo",
|
||||||
|
"var_icons_short": "Icônes",
|
||||||
|
"var_apparent": "Ressenti",
|
||||||
|
"var_apparent_short": "Ressenti",
|
||||||
|
"var_dew_point": "Point de rosée",
|
||||||
|
"var_dew_point_short": "Rosée",
|
||||||
|
"var_cloud": "Nébulosité",
|
||||||
|
"var_cloud_short": "Nuages",
|
||||||
|
"var_cloud_low": "Nébulosité basse",
|
||||||
|
"var_cloud_low_short": "Basse",
|
||||||
|
"var_cloud_mid": "Nébulosité moyenne",
|
||||||
|
"var_cloud_mid_short": "Moyenne",
|
||||||
|
"var_cloud_high": "Nébulosité haute",
|
||||||
|
"var_cloud_high_short": "Haute",
|
||||||
|
"var_precipitation": "Précipitations",
|
||||||
|
"var_precipitation_short": "Précip.",
|
||||||
|
"var_pop": "Probabilité de précip.",
|
||||||
|
"var_pop_short": "Prob.",
|
||||||
|
"var_rain": "Pluie",
|
||||||
|
"var_rain_short": "Pluie",
|
||||||
|
"var_showers": "Averses",
|
||||||
|
"var_showers_short": "Averses",
|
||||||
|
"var_snowfall": "Chutes de neige",
|
||||||
|
"var_snowfall_short": "Neige",
|
||||||
|
"var_wind": "Vitesse du vent",
|
||||||
|
"var_wind_short": "Vent",
|
||||||
|
"var_wind_dir": "Direction du vent",
|
||||||
|
"var_wind_dir_short": "Dir.",
|
||||||
|
"var_gusts": "Rafales",
|
||||||
|
"var_gusts_short": "Rafales",
|
||||||
|
"var_humidity": "Humidité",
|
||||||
|
"var_humidity_short": "HR",
|
||||||
|
"var_pressure": "Pression (MSL)",
|
||||||
|
"var_pressure_short": "MSLP",
|
||||||
|
"var_surface_pressure": "Pression au sol",
|
||||||
|
"var_surface_pressure_short": "Psfc",
|
||||||
|
"var_uv": "Indice UV",
|
||||||
|
"var_uv_short": "UV",
|
||||||
|
"var_visibility": "Visibilité",
|
||||||
|
"var_visibility_short": "Vis.",
|
||||||
|
"var_cape": "CAPE",
|
||||||
|
"var_cape_short": "CAPE",
|
||||||
|
"var_time": "Heure",
|
||||||
|
"no_data_title": "Ce modèle n'a pas de données ici",
|
||||||
|
"no_data_body": "Le modèle sélectionné ne couvre pas {location} : les modèles régionaux ne fournissent des données que dans leur propre zone.",
|
||||||
|
"no_data_try_city": "Changer de lieu pour {city}",
|
||||||
|
"no_data_best_match": "Passer à « Best match »",
|
||||||
|
"model_archive": "Réanalyse",
|
||||||
|
"model_seasonal": "Modèle saisonnier",
|
||||||
|
"nearby_cities_title": "Villes à proximité",
|
||||||
|
"nearby_cities_subtitle": "Maximales et minimales du jour sélectionné",
|
||||||
|
"supporter_dialog_title": "Soutien Drizz.li",
|
||||||
|
"supporter_dialog_desc": "Collez la clé d'accès reçue par e-mail pour débloquer les bonus.",
|
||||||
|
"supporter_extras_active": "Les bonus contributeur sont actifs",
|
||||||
|
"supporter_lifetime": "Accès à vie",
|
||||||
|
"supporter_active_until": "Actif jusqu'au {date}",
|
||||||
|
"supporter_access_key": "Clé d'accès",
|
||||||
|
"supporter_key_invalid": "Cette clé n'est pas valide ou a expiré.",
|
||||||
|
"supporter_server_unreachable": "Serveur injoignable. Vérifiez votre connexion et réessayez.",
|
||||||
|
"supporter_verifying": "Vérification…",
|
||||||
|
"supporter_unlock_button": "Débloquer",
|
||||||
|
"supporter_remove_key": "Supprimer la clé de cet appareil",
|
||||||
|
"supporter_no_key_yet": "Pas encore de clé ?",
|
||||||
|
"supporter_support_from": "Soutenez le projet à partir de {price}",
|
||||||
|
"supporter_checking": "Vérification de votre abonnement…",
|
||||||
|
"supporter_gate_title": "{feature} est un bonus contributeur",
|
||||||
|
"supporter_gate_body": "Drizz.li est gratuit et open source. Contribuez à partir de {price} pour le faire vivre - en remerciement, les contributeurs débloquent les bonus.",
|
||||||
|
"supporter_become": "Devenir contributeur",
|
||||||
|
"supporter_have_key": "J'ai une clé",
|
||||||
|
"supporter_key_expired": "Votre clé enregistrée n'est plus valide ou a expiré.",
|
||||||
|
"supporter_enter_key": "Saisissez votre clé d'accès.",
|
||||||
|
"supporter_perk_historical": "Météo historique et comparaison aux normales climatiques",
|
||||||
|
"supporter_perk_seasonal": "Aperçu saisonnier : les mois à venir face à la normale climatique",
|
||||||
|
"supporter_perk_future": "Les nouveaux bonus dès leur sortie",
|
||||||
|
"action_close": "Fermer",
|
||||||
|
"action_done": "Terminé",
|
||||||
|
"action_try_again": "Réessayer",
|
||||||
|
"action_reset_defaults": "Rétablir les valeurs par défaut",
|
||||||
|
"customize_meteograms": "Personnaliser les météogrammes",
|
||||||
|
"customizer_intro": "Faites glisser les variables d'un graphique à l'autre pour composer votre mise en page.",
|
||||||
|
"customizer_chart_n": "Graphique {number}",
|
||||||
|
"customizer_delete_chart": "Supprimer le graphique {number}",
|
||||||
|
"customizer_add_chart": "+ Ajouter un graphique",
|
||||||
|
"customizer_available": "Variables disponibles",
|
||||||
|
"customizer_drag": "Déplacer {variable}",
|
||||||
|
"customizer_remove": "Retirer {variable}",
|
||||||
|
"customizer_drop_here": "Déposez des variables ici",
|
||||||
|
"customizer_all_in_use": "Toutes les variables sont utilisées",
|
||||||
|
"variables_aria": "Sélection des variables",
|
||||||
|
"variables_close": "Fermer la sélection des variables",
|
||||||
|
"variables_table_section": "Tableau horaire",
|
||||||
|
"variables_move_up": "Déplacer {variable} vers le haut",
|
||||||
|
"variables_move_down": "Déplacer {variable} vers le bas",
|
||||||
|
"variables_charts_hint_before": "Les variables des météogrammes se règlent avec le bouton",
|
||||||
|
"variables_charts_hint_after": "au-dessus des graphiques.",
|
||||||
|
"table_customize": "Personnaliser les variables",
|
||||||
|
"table_interval_aria": "Intervalle horaire",
|
||||||
|
"table_now": "Maintenant",
|
||||||
|
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
|
||||||
|
"page_loading": "Chargement…",
|
||||||
|
"page_loading_dismiss": "Fermer",
|
||||||
|
"charts_loading": "Chargement des graphiques…",
|
||||||
|
"chart_download": "Télécharger le météogramme en PNG",
|
||||||
|
"chart_credit_viz": "visualisation par",
|
||||||
|
"chart_toggle_series": "Afficher ou masquer {series}",
|
||||||
|
"legend_show": "Afficher la légende",
|
||||||
|
"meteograms_none_before": "Aucun météogramme configuré,",
|
||||||
|
"meteograms_none_action": "ajoutez des variables",
|
||||||
|
"meteograms_none_historical": "Aucun météogramme configuré. Ajoutez des variables depuis la page des prévisions hebdomadaires.",
|
||||||
|
"search_searching": "Recherche…",
|
||||||
|
"search_favorites": "Favoris",
|
||||||
|
"search_recent": "Récents",
|
||||||
|
"search_hint": "Commencez à taper ou utilisez le GPS pour détecter votre position",
|
||||||
|
"search_no_results": "Aucun lieu trouvé",
|
||||||
|
"search_remove_recent": "Retirer {location} des lieux récents",
|
||||||
|
"search_remove_recent_short": "Retirer des récents",
|
||||||
|
"search_aria": "Rechercher un lieu",
|
||||||
|
"search_gps": "Utiliser la position GPS",
|
||||||
|
"model_automatic_selection": "Sélection automatique",
|
||||||
|
"model_selector_aria": "Sélection : {label}",
|
||||||
|
"compare_models_heading": "Modèles",
|
||||||
|
"compare_models_choose": "Choisir les modèles à comparer",
|
||||||
|
"compare_variables_heading": "Variables météo horaires",
|
||||||
|
"compare_standard_preset": "Comparaison standard",
|
||||||
|
"compare_weather_conditions": "Conditions & couverture nuageuse",
|
||||||
|
"compare_wind_direction_height": "Direction du vent à {height} m",
|
||||||
|
"compare_standard_preset_description": "Un point de départ équilibré pour comparer les modèles de prévision.",
|
||||||
|
"compare_preset_active": "Active",
|
||||||
|
"compare_custom_selection": "Personnalisée · {count} sélectionnées",
|
||||||
|
"compare_customize_variables": "Personnaliser les variables",
|
||||||
|
"compare_restore_defaults": "Rétablir la sélection standard",
|
||||||
|
"compare_variables_pending": "Sélection des variables modifiée",
|
||||||
|
"compare_selected_models": "Modèles sélectionnés",
|
||||||
|
"compare_selection_pending": "Sélection modifiée",
|
||||||
|
"compare_apply_selection": "Appliquer et recharger les graphiques",
|
||||||
|
"compare_discard_changes": "Annuler les modifications",
|
||||||
|
"compare_customize_models": "Personnaliser les modèles",
|
||||||
|
"compare_edit_models": "Modifier les modèles",
|
||||||
|
"compare_edit_variables": "Modifier les variables",
|
||||||
|
"compare_edit_models_description": "Recherchez des modèles ou sélectionnez des fournisseurs entiers.",
|
||||||
|
"compare_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques entiers.",
|
||||||
|
"compare_search_models": "Rechercher des modèles",
|
||||||
|
"compare_search_variables": "Rechercher des variables",
|
||||||
|
"compare_show_model_names": "Afficher les noms des modèles",
|
||||||
|
"compare_hide_model_names": "Masquer les noms des modèles",
|
||||||
|
"compare_only_selected": "Sélection uniquement",
|
||||||
|
"compare_select_group": "Tout sélectionner",
|
||||||
|
"compare_clear_group": "Tout désélectionner",
|
||||||
|
"compare_no_matching_options": "Aucune option correspondante.",
|
||||||
|
"compare_no_models_selected": "Aucun modèle sélectionné.",
|
||||||
|
"compare_remove_model": "Supprimer {model}",
|
||||||
|
"compare_timeline_title": "Chronologie de comparaison des modèles",
|
||||||
|
"compare_model_label": "Modèle",
|
||||||
|
"page_compare_title": "Comparaison des modèles météo",
|
||||||
|
"page_compare_description": "Comparez les prévisions horaires de plusieurs modèles météo pour n’importe quel lieu.",
|
||||||
|
"compare_direction_subtitle": "Dispersion des directions sur {count} modèles · sans moyenne",
|
||||||
|
"compare_scalar_subtitle": "Sur {count} modèles · pointillés = moyenne des modèles",
|
||||||
|
"compare_precipitation_subtitle": "Sur {count} modèles · bande inférieure = accord sur les précipitations",
|
||||||
|
"compare_precipitation_agreement": "Accord",
|
||||||
|
"compare_precipitation_agreement_tooltip": "{wet}/{available} avec précipitations · médiane {median} {unit} · plage {min}–{max} {unit}",
|
||||||
|
"compare_model_mean": "Moyenne des modèles",
|
||||||
|
"compare_showing_previous": "La dernière comparaison réussie reste affichée.",
|
||||||
|
"compare_empty_selection": "Sélectionnez au moins un modèle et une variable météo.",
|
||||||
|
"compare_no_data": "Aucun des modèles sélectionnés ne fournit de données exploitables pour ce lieu et cette sélection.",
|
||||||
|
"compare_weather_codes_only": "Les conditions et la couverture nuageuse sont affichées dans la chronologie ci-dessous. Sélectionnez une autre variable pour ajouter un graphique.",
|
||||||
|
"compare_model_colors": "Couleurs des modèles",
|
||||||
|
"compare_group_temperature": "Température et humidité",
|
||||||
|
"compare_group_precipitation": "Précipitations et conditions",
|
||||||
|
"compare_group_clouds": "Pression et nuages",
|
||||||
|
"compare_group_wind": "Vent et atmosphère",
|
||||||
|
"compare_group_upper_air": "Température en altitude",
|
||||||
|
"compare_many_models_warning": "{count} modèles sont sélectionnés. La comparaison peut être plus lente et les couleurs plus difficiles à distinguer.",
|
||||||
|
"compare_timeline_hint": "Conditions par modèle ; le fond des pictogrammes indique la couverture nuageuse totale",
|
||||||
|
"compare_timeline_scroll_aria": "Chronologie météo des modèles à défilement horizontal",
|
||||||
|
"compare_timeline_caption": "Conditions météo horaires par modèle de prévision",
|
||||||
|
"ensemble_trimmed": "L'ensemble de ce modèle ne va qu'à environ {days} jours, la dispersion est donc limitée à sa portée disponible.",
|
||||||
|
"seasonal_explainer_before": "Une prévision saisonnière montre dans quelle mesure un mois entier devrait",
|
||||||
|
"seasonal_explainer_strong": "s'écarter de sa normale climatique",
|
||||||
|
"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 1991–2020…",
|
||||||
|
"normal_band_legend": "bande normale = moyenne 1991–2020",
|
||||||
|
"anomaly_vs_normal": "{value} par rapport à la normale",
|
||||||
|
"stat_avg_temperature": "Température moyenne",
|
||||||
|
"stat_mean_temperature": "Température moyenne",
|
||||||
|
"stat_total_precipitation": "Précipitations totales",
|
||||||
|
"stat_warmest_day": "Jour le plus chaud",
|
||||||
|
"stat_coldest_day": "Jour le plus froid",
|
||||||
|
"historical_daily_heading": "Quotidien",
|
||||||
|
"historical_daily_hint": "– sélectionnez un jour pour le détail horaire",
|
||||||
|
"historical_full_range": "– période complète",
|
||||||
|
"historical_from": "Du",
|
||||||
|
"historical_to": "Au",
|
||||||
|
"historical_quick_ranges": "Plages rapides",
|
||||||
|
"historical_last_days": "{days} jours",
|
||||||
|
"historical_month_last_year": "Ce mois, l'an dernier",
|
||||||
|
"label_day": "jour",
|
||||||
|
"label_night": "nuit",
|
||||||
|
"daycards_past": "3 jours passés",
|
||||||
|
"daycards_load_15": "Charger 15 jours",
|
||||||
|
"error_technical_details": "Détails techniques",
|
||||||
|
"err_network_title": "Service météo injoignable",
|
||||||
|
"err_network_hint": "Vérifiez votre connexion internet et réessayez.",
|
||||||
|
"err_nodata_title": "Aucune donnée pour ce lieu avec le modèle sélectionné",
|
||||||
|
"err_nodata_hint": "Les modèles régionaux ne couvrent que leur propre zone - \"Best match\" choisit automatiquement un modèle adapté.",
|
||||||
|
"err_rejected_title": "Le service météo a refusé la requête",
|
||||||
|
"err_rejected_hint": "Essayez d'autres réglages ou revenez au modèle \"Best match\".",
|
||||||
|
"err_generic_title": "Le chargement des données météo a échoué",
|
||||||
|
"err_generic_hint": "Réessayez dans un instant. Si cela persiste, revenez au modèle \"Best match\".",
|
||||||
|
"maps_iframe_title": "Carte interactive Open-Meteo",
|
||||||
|
"page_maps_title": "Carte météo",
|
||||||
|
"page_week_title": "Météo",
|
||||||
|
"search_favorite_add": "Ajouter aux favoris",
|
||||||
|
"search_favorite_remove": "Retirer des favoris",
|
||||||
|
"supporter_price_per_month": "{amount} / mois",
|
||||||
|
"model_best_match_hint": "Choisit automatiquement le meilleur modèle pour ce lieu",
|
||||||
|
"model_updated": "mis à jour {cadence}",
|
||||||
|
"cadence_every_hour": "toutes les heures",
|
||||||
|
"cadence_every_hours": "toutes les {hours} h",
|
||||||
|
"cadence_daily": "chaque jour",
|
||||||
|
"cadence_monthly": "chaque mois",
|
||||||
|
"cadence_varies": "variable",
|
||||||
|
"model_group_automatic": "Automatique",
|
||||||
|
"model_group_reanalysis": "Réanalyse ECMWF",
|
||||||
|
"model_group_regional_reanalysis": "Réanalyse régionale",
|
||||||
|
"wmo_0": "Ciel dégagé",
|
||||||
|
"wmo_1": "Plutôt dégagé",
|
||||||
|
"wmo_2": "Partiellement nuageux",
|
||||||
|
"wmo_3": "Couvert",
|
||||||
|
"wmo_45": "Brouillard",
|
||||||
|
"wmo_48": "Brouillard givrant",
|
||||||
|
"wmo_51": "Bruine faible",
|
||||||
|
"wmo_53": "Bruine modérée",
|
||||||
|
"wmo_55": "Bruine dense",
|
||||||
|
"wmo_56": "Bruine verglaçante faible",
|
||||||
|
"wmo_57": "Bruine verglaçante dense",
|
||||||
|
"wmo_61": "Pluie faible",
|
||||||
|
"wmo_63": "Pluie modérée",
|
||||||
|
"wmo_65": "Pluie forte",
|
||||||
|
"wmo_66": "Pluie verglaçante faible",
|
||||||
|
"wmo_67": "Pluie verglaçante forte",
|
||||||
|
"wmo_71": "Neige faible",
|
||||||
|
"wmo_73": "Neige modérée",
|
||||||
|
"wmo_75": "Neige forte",
|
||||||
|
"wmo_77": "Grésil",
|
||||||
|
"wmo_80": "Averses faibles",
|
||||||
|
"wmo_81": "Averses modérées",
|
||||||
|
"wmo_82": "Averses violentes",
|
||||||
|
"wmo_85": "Averses de neige faibles",
|
||||||
|
"wmo_86": "Averses de neige fortes",
|
||||||
|
"wmo_95": "Orage",
|
||||||
|
"wmo_96": "Orage avec grêle légère",
|
||||||
|
"wmo_99": "Orage avec forte grêle",
|
||||||
|
"summary_read_more": "Lire la suite",
|
||||||
|
"summary_read_less": "Afficher moins"
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"nav_week": "Previsioni settimanali",
|
||||||
|
"nav_compare": "Confronto modelli",
|
||||||
|
"nav_14day": "Previsioni a 14 giorni",
|
||||||
|
"nav_seasonal": "Stagionale",
|
||||||
|
"nav_historical": "Storico",
|
||||||
|
"nav_maps": "Mappe",
|
||||||
|
"nav_collapse": "Comprimi",
|
||||||
|
"nav_expand_sidebar": "Espandi barra laterale",
|
||||||
|
"nav_collapse_sidebar": "Comprimi barra laterale",
|
||||||
|
"nav_home": "Home di Drizzli",
|
||||||
|
"nav_toggle_menu": "Attiva/disattiva menu",
|
||||||
|
"search_placeholder": "Cerca una località…",
|
||||||
|
"settings_title": "Impostazioni",
|
||||||
|
"units_title": "Unità",
|
||||||
|
"units_aria": "Scegli le unità di misura",
|
||||||
|
"unit_temperature": "Temperatura",
|
||||||
|
"unit_wind_speed": "Velocità del vento",
|
||||||
|
"unit_precipitation": "Precipitazioni",
|
||||||
|
"theme_label": "Tema",
|
||||||
|
"theme_system": "Sistema",
|
||||||
|
"theme_light": "Chiaro",
|
||||||
|
"theme_dark": "Scuro",
|
||||||
|
"theme_follow_system": "Tema: segui il sistema",
|
||||||
|
"theme_light_title": "Tema: chiaro",
|
||||||
|
"theme_dark_title": "Tema: scuro",
|
||||||
|
"language_label": "Lingua",
|
||||||
|
"supporter_active": "Extra sostenitore attivi",
|
||||||
|
"supporter_support": "Sostieni Drizz.li",
|
||||||
|
"supporter_manage_key": "Gestisci la tua chiave di accesso",
|
||||||
|
"supporter_unlock": "Sblocca gli extra sostenitore",
|
||||||
|
"supporter_badge": "Sostenitore",
|
||||||
|
"strip_past_label": "prima",
|
||||||
|
"strip_past_aria": "Carica gli ultimi 3 giorni",
|
||||||
|
"strip_days_label": "giorni",
|
||||||
|
"strip_extend_aria": "Mostra le previsioni complete a 15 giorni",
|
||||||
|
"strip_history_label": "archivio",
|
||||||
|
"strip_history_aria": "Apri l'archivio meteo",
|
||||||
|
"strip_history_title": "Storico: qualsiasi data dal 1940",
|
||||||
|
"strip_seasonal_label": "stagionale",
|
||||||
|
"strip_seasonal_aria": "Apri le tendenze stagionali",
|
||||||
|
"strip_seasonal_title": "Tendenze stagionali: i prossimi mesi",
|
||||||
|
"day_today": "Oggi",
|
||||||
|
"day_tomorrow": "Domani",
|
||||||
|
"day_yesterday": "Ieri",
|
||||||
|
"page_week_subtitle": "Previsioni settimanali",
|
||||||
|
"page_compare_subtitle": "Confronto modelli",
|
||||||
|
"page_14day_subtitle": "Previsione d'insieme a 14 giorni",
|
||||||
|
"page_seasonal_subtitle": "Tendenze stagionali",
|
||||||
|
"page_historical_subtitle": "Meteo storico",
|
||||||
|
"hourly_heading": "orario",
|
||||||
|
"hourly_variables": "Variabili",
|
||||||
|
"meteograms_heading": "Meteogrammi",
|
||||||
|
"meteograms_customize": "Personalizza",
|
||||||
|
"meteograms_zoom_hint": "trascina o",
|
||||||
|
"meteograms_zoom_hint_end": "+ scorri per ingrandire",
|
||||||
|
"range_today": "Oggi",
|
||||||
|
"range_selected_day": "Giorno selezionato",
|
||||||
|
"range_3_days": "3 giorni",
|
||||||
|
"range_5_days": "5 giorni",
|
||||||
|
"range_all": "Tutto",
|
||||||
|
"range_group_aria": "Intervallo temporale del grafico",
|
||||||
|
"reset_zoom": "Reimposta zoom",
|
||||||
|
"default_range_title": "Intervallo temporale predefinito",
|
||||||
|
"default_range_auto": "Auto",
|
||||||
|
"default_range_auto_hint": "3 giorni su cellulare, tutto su schermi ampi",
|
||||||
|
"summary_heading": "in parole",
|
||||||
|
"summary_no_data": "Nessun dato orario per questo giorno.",
|
||||||
|
"label_sunrise": "Alba",
|
||||||
|
"label_sunset": "Tramonto",
|
||||||
|
"label_daylight": "Ore di luce",
|
||||||
|
"label_uv_index": "Indice UV",
|
||||||
|
"label_moonrise": "Sorgere della luna",
|
||||||
|
"label_moonset": "Tramonto della luna",
|
||||||
|
"label_moon": "Luna",
|
||||||
|
"daylight_hours": "{hours} h {minutes} min",
|
||||||
|
"sunshine_share": "{percent}% di sole",
|
||||||
|
"uv_low": "Basso",
|
||||||
|
"uv_moderate": "Moderato",
|
||||||
|
"uv_high": "Alto",
|
||||||
|
"uv_very_high": "Molto alto",
|
||||||
|
"uv_extreme": "Estremo",
|
||||||
|
"moon_new": "Luna nuova",
|
||||||
|
"moon_waxing_crescent": "Luna crescente",
|
||||||
|
"moon_first_quarter": "Primo quarto",
|
||||||
|
"moon_waxing_gibbous": "Gibbosa crescente",
|
||||||
|
"moon_full": "Luna piena",
|
||||||
|
"moon_waning_gibbous": "Gibbosa calante",
|
||||||
|
"moon_last_quarter": "Ultimo quarto",
|
||||||
|
"moon_waning_crescent": "Luna calante",
|
||||||
|
"cond_clear": "sereno",
|
||||||
|
"cond_fair": "parzialmente nuvoloso",
|
||||||
|
"cond_cloudy": "coperto",
|
||||||
|
"cond_fog": "nebbioso",
|
||||||
|
"cond_drizzle": "con pioviggine",
|
||||||
|
"cond_rain": "piovoso",
|
||||||
|
"cond_snow": "nevoso",
|
||||||
|
"cond_thunder": "temporalesco",
|
||||||
|
"period_overnight": "durante la notte",
|
||||||
|
"period_morning": "al mattino",
|
||||||
|
"period_afternoon": "nel pomeriggio",
|
||||||
|
"period_evening": "in serata",
|
||||||
|
"footer_tagline": "Previsioni meteo rapide e senza fronzoli, basate su dati aperti.",
|
||||||
|
"footer_data_by": "Dati meteo da",
|
||||||
|
"footer_forecasts": "Previsioni",
|
||||||
|
"footer_popular": "Località popolari",
|
||||||
|
"footer_about_section": "Informazioni",
|
||||||
|
"model_weather": "Modello meteo",
|
||||||
|
"model_ensemble": "Modello d'insieme",
|
||||||
|
"sky_all_1": "{condition} per tutta la giornata.",
|
||||||
|
"sky_all_2": "Una giornata {condition} dall'inizio alla fine.",
|
||||||
|
"sky_all_3": "Il cielo resta {condition} per tutto il giorno.",
|
||||||
|
"sky_two_1": "{c1} {p1}, poi {c2} {p2}.",
|
||||||
|
"sky_two_2": "La giornata si apre {c1} {p1} prima di diventare {c2} {p2}.",
|
||||||
|
"sky_two_3": "Cielo {c1} {p1} e poi {c2} {p2}.",
|
||||||
|
"sky_three_1": "{c1} {p1}, poi {c2} {p2} e infine {c3} {p3}.",
|
||||||
|
"sky_three_2": "Inizia {c1} {p1}, diventa {c2} {p2} e finisce {c3} {p3}.",
|
||||||
|
"sky_three_3": "Da cielo {c1} {p1} a {c2} {p2}, per poi restare {c3} {p3}.",
|
||||||
|
"temp_1": "Massime intorno a {high} e minime di {low} durante la notte.",
|
||||||
|
"temp_2": "Le temperature salgono fino a {high} e tornano a {low} dopo il tramonto.",
|
||||||
|
"temp_3": "Tra {low} e {high} nel corso della giornata.",
|
||||||
|
"temp_feels_1": "Massime intorno a {high} e minime di {low}, ma con una percezione di {feels}.",
|
||||||
|
"temp_feels_2": "Fino a {high} sul termometro, percepiti {feels}, prima di scendere a {low}.",
|
||||||
|
"temp_feels_3": "Si arriva a {high} ma se ne percepiscono {feels}, con {low} più tardi.",
|
||||||
|
"precip_window_1": "Circa {amount} di precipitazioni, soprattutto {when}.",
|
||||||
|
"precip_window_2": "Sono attesi circa {amount}, concentrati {when}.",
|
||||||
|
"precip_window_3": "Il periodo più piovoso è {when}, per un totale di circa {amount}.",
|
||||||
|
"precip_spread_1": "Circa {amount} di precipitazioni distribuite nell'arco della giornata.",
|
||||||
|
"precip_spread_2": "Circa {amount} cadono a intermittenza durante il giorno.",
|
||||||
|
"precip_spread_3": "Rovesci a intermittenza, per un totale di circa {amount}.",
|
||||||
|
"precip_chance_1": "Perlopiù asciutto, con una probabilità di rovesci fino al {percent}%.",
|
||||||
|
"precip_chance_2": "Poca pioggia prevista, ma c'è il {percent}% di probabilità di un rovescio di passaggio.",
|
||||||
|
"precip_chance_3": "Un {percent}% di rischio rovesci, ma nulla da ombrello.",
|
||||||
|
"precip_dry_1": "Resterà asciutto tutto il giorno.",
|
||||||
|
"precip_dry_2": "Non è attesa una goccia.",
|
||||||
|
"precip_dry_3": "Asciutto dall'inizio alla fine.",
|
||||||
|
"wind_dir_1": "Vento da {direction} fino a {speed}.",
|
||||||
|
"wind_dir_2": "Una brezza da {direction} tocca i {speed}.",
|
||||||
|
"wind_dir_3": "Il vento si mantiene da {direction}, con punte vicine a {speed}.",
|
||||||
|
"wind_dir_gusts_1": "Vento da {direction} fino a {speed}, con raffiche a {gust}.",
|
||||||
|
"wind_dir_gusts_2": "Un vento da {direction} di {speed}, con raffiche fino a {gust}.",
|
||||||
|
"wind_dir_gusts_3": "Attesi {speed} da {direction}, con raffiche di {gust} a tratti.",
|
||||||
|
"wind_1": "Vento fino a {speed}.",
|
||||||
|
"wind_2": "La brezza tocca circa {speed}.",
|
||||||
|
"wind_3": "Aria in movimento fino a {speed}.",
|
||||||
|
"wind_gusts_1": "Vento fino a {speed}, con raffiche a {gust}.",
|
||||||
|
"wind_gusts_2": "Fino a {speed}, con raffiche che arrivano a {gust}.",
|
||||||
|
"wind_gusts_3": "Giornata ventosa: {speed} costanti, {gust} nelle raffiche.",
|
||||||
|
"calm_1": "Quasi assenza di vento.",
|
||||||
|
"calm_2": "L'aria resta quasi immobile.",
|
||||||
|
"calm_3": "Praticamente senza vento per tutto il giorno.",
|
||||||
|
"uv_1": "Indice UV fino a {value} ({label}): proteggiti a metà giornata.",
|
||||||
|
"uv_2": "Il sole picchia a mezzogiorno: UV {value}, {label}.",
|
||||||
|
"uv_3": "Meglio cercare ombra a metà giornata: l'UV arriva a {value} ({label}).",
|
||||||
|
"legal_about": "Informazioni",
|
||||||
|
"legal_imprint": "Note legali",
|
||||||
|
"legal_privacy": "Privacy",
|
||||||
|
"legal_terms": "Condizioni",
|
||||||
|
"legal_nav": "Informazioni legali",
|
||||||
|
"city_weather": "Meteo a {city}",
|
||||||
|
"var_temperature": "Temperatura",
|
||||||
|
"var_temperature_short": "Temp",
|
||||||
|
"var_icons": "Icone meteo",
|
||||||
|
"var_icons_short": "Icone",
|
||||||
|
"var_apparent": "Percepita",
|
||||||
|
"var_apparent_short": "Percepita",
|
||||||
|
"var_dew_point": "Punto di rugiada",
|
||||||
|
"var_dew_point_short": "Rugiada",
|
||||||
|
"var_cloud": "Nuvolosità",
|
||||||
|
"var_cloud_short": "Nuvole",
|
||||||
|
"var_cloud_low": "Nuvolosità bassa",
|
||||||
|
"var_cloud_low_short": "Bassa",
|
||||||
|
"var_cloud_mid": "Nuvolosità media",
|
||||||
|
"var_cloud_mid_short": "Media",
|
||||||
|
"var_cloud_high": "Nuvolosità alta",
|
||||||
|
"var_cloud_high_short": "Alta",
|
||||||
|
"var_precipitation": "Precipitazioni",
|
||||||
|
"var_precipitation_short": "Precip.",
|
||||||
|
"var_pop": "Probabilità di precip.",
|
||||||
|
"var_pop_short": "Prob.",
|
||||||
|
"var_rain": "Pioggia",
|
||||||
|
"var_rain_short": "Pioggia",
|
||||||
|
"var_showers": "Rovesci",
|
||||||
|
"var_showers_short": "Rovesci",
|
||||||
|
"var_snowfall": "Nevicate",
|
||||||
|
"var_snowfall_short": "Neve",
|
||||||
|
"var_wind": "Velocità del vento",
|
||||||
|
"var_wind_short": "Vento",
|
||||||
|
"var_wind_dir": "Direzione del vento",
|
||||||
|
"var_wind_dir_short": "Dir.",
|
||||||
|
"var_gusts": "Raffiche",
|
||||||
|
"var_gusts_short": "Raffiche",
|
||||||
|
"var_humidity": "Umidità",
|
||||||
|
"var_humidity_short": "UR",
|
||||||
|
"var_pressure": "Pressione (MSL)",
|
||||||
|
"var_pressure_short": "MSLP",
|
||||||
|
"var_surface_pressure": "Pressione al suolo",
|
||||||
|
"var_surface_pressure_short": "Psfc",
|
||||||
|
"var_uv": "Indice UV",
|
||||||
|
"var_uv_short": "UV",
|
||||||
|
"var_visibility": "Visibilità",
|
||||||
|
"var_visibility_short": "Vis.",
|
||||||
|
"var_cape": "CAPE",
|
||||||
|
"var_cape_short": "CAPE",
|
||||||
|
"var_time": "Ora",
|
||||||
|
"no_data_title": "Nessun dato per questo modello qui",
|
||||||
|
"no_data_body": "Il modello selezionato non copre {location}: i modelli regionali forniscono dati solo nella propria area.",
|
||||||
|
"no_data_try_city": "Cambia località in {city}",
|
||||||
|
"no_data_best_match": "Passa a «Best match»",
|
||||||
|
"model_archive": "Rianalisi",
|
||||||
|
"model_seasonal": "Modello stagionale",
|
||||||
|
"nearby_cities_title": "Città nelle vicinanze",
|
||||||
|
"nearby_cities_subtitle": "Massime e minime del giorno selezionato",
|
||||||
|
"supporter_dialog_title": "Sostenitore Drizz.li",
|
||||||
|
"supporter_dialog_desc": "Incolla la chiave di accesso ricevuta via e-mail per sbloccare gli extra.",
|
||||||
|
"supporter_extras_active": "Gli extra per sostenitori sono attivi",
|
||||||
|
"supporter_lifetime": "Accesso a vita",
|
||||||
|
"supporter_active_until": "Attivo fino al {date}",
|
||||||
|
"supporter_access_key": "Chiave di accesso",
|
||||||
|
"supporter_key_invalid": "Questa chiave non è valida o è scaduta.",
|
||||||
|
"supporter_server_unreachable": "Server non raggiungibile. Controlla la connessione e riprova.",
|
||||||
|
"supporter_verifying": "Verifica in corso…",
|
||||||
|
"supporter_unlock_button": "Sblocca",
|
||||||
|
"supporter_remove_key": "Rimuovi la chiave da questo dispositivo",
|
||||||
|
"supporter_no_key_yet": "Non hai ancora una chiave?",
|
||||||
|
"supporter_support_from": "Sostieni il progetto da {price}",
|
||||||
|
"supporter_checking": "Verifica dell'abbonamento…",
|
||||||
|
"supporter_gate_title": "{feature} è un extra per sostenitori",
|
||||||
|
"supporter_gate_body": "Drizz.li è gratuito e open source. Contribuisci da {price} per tenerlo in vita - come ringraziamento, i sostenitori sbloccano gli extra.",
|
||||||
|
"supporter_become": "Diventa sostenitore",
|
||||||
|
"supporter_have_key": "Ho una chiave",
|
||||||
|
"supporter_key_expired": "La chiave salvata non è più valida o è scaduta.",
|
||||||
|
"supporter_enter_key": "Inserisci la tua chiave di accesso.",
|
||||||
|
"supporter_perk_historical": "Meteo storico e confronto con le normali climatiche",
|
||||||
|
"supporter_perk_seasonal": "Prospettive stagionali: i mesi a venire rispetto alla normale climatica",
|
||||||
|
"supporter_perk_future": "Nuovi extra non appena arrivano",
|
||||||
|
"action_close": "Chiudi",
|
||||||
|
"action_done": "Fatto",
|
||||||
|
"action_try_again": "Riprova",
|
||||||
|
"action_reset_defaults": "Ripristina i valori predefiniti",
|
||||||
|
"customize_meteograms": "Personalizza i meteogrammi",
|
||||||
|
"customizer_intro": "Trascina le variabili tra i grafici per costruire il tuo layout.",
|
||||||
|
"customizer_chart_n": "Grafico {number}",
|
||||||
|
"customizer_delete_chart": "Elimina il grafico {number}",
|
||||||
|
"customizer_add_chart": "+ Aggiungi grafico",
|
||||||
|
"customizer_available": "Variabili disponibili",
|
||||||
|
"customizer_drag": "Trascina {variable}",
|
||||||
|
"customizer_remove": "Rimuovi {variable}",
|
||||||
|
"customizer_drop_here": "Trascina qui le variabili",
|
||||||
|
"customizer_all_in_use": "Tutte le variabili sono in uso",
|
||||||
|
"variables_aria": "Selezione delle variabili",
|
||||||
|
"variables_close": "Chiudi la selezione delle variabili",
|
||||||
|
"variables_table_section": "Tabella oraria",
|
||||||
|
"variables_move_up": "Sposta {variable} in alto",
|
||||||
|
"variables_move_down": "Sposta {variable} in basso",
|
||||||
|
"variables_charts_hint_before": "Le variabili dei meteogrammi si impostano con il pulsante",
|
||||||
|
"variables_charts_hint_after": "sopra i grafici.",
|
||||||
|
"table_customize": "Personalizza le variabili",
|
||||||
|
"table_interval_aria": "Intervallo orario",
|
||||||
|
"table_now": "Ora",
|
||||||
|
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
|
||||||
|
"page_loading": "Caricamento…",
|
||||||
|
"page_loading_dismiss": "Chiudi",
|
||||||
|
"charts_loading": "Caricamento dei grafici…",
|
||||||
|
"chart_download": "Scarica il meteogramma come immagine PNG",
|
||||||
|
"chart_credit_viz": "visualizzazione di",
|
||||||
|
"chart_toggle_series": "Mostra o nascondi {series}",
|
||||||
|
"legend_show": "Mostra legenda",
|
||||||
|
"meteograms_none_before": "Nessun meteogramma configurato,",
|
||||||
|
"meteograms_none_action": "aggiungi qualche variabile",
|
||||||
|
"meteograms_none_historical": "Nessun meteogramma configurato. Aggiungi variabili dalla pagina delle previsioni settimanali.",
|
||||||
|
"search_searching": "Ricerca in corso…",
|
||||||
|
"search_favorites": "Preferiti",
|
||||||
|
"search_recent": "Recenti",
|
||||||
|
"search_hint": "Inizia a digitare o usa il GPS per rilevare la tua posizione",
|
||||||
|
"search_no_results": "Nessuna località trovata",
|
||||||
|
"search_remove_recent": "Rimuovi {location} dai luoghi recenti",
|
||||||
|
"search_remove_recent_short": "Rimuovi dai recenti",
|
||||||
|
"search_aria": "Cerca una località",
|
||||||
|
"search_gps": "Usa la posizione GPS",
|
||||||
|
"model_automatic_selection": "Selezione automatica",
|
||||||
|
"model_selector_aria": "Selezione di {label}",
|
||||||
|
"compare_models_heading": "Modelli",
|
||||||
|
"compare_models_choose": "Scegli i modelli da confrontare",
|
||||||
|
"compare_variables_heading": "Variabili meteo orarie",
|
||||||
|
"compare_standard_preset": "Confronto standard",
|
||||||
|
"compare_weather_conditions": "Condizioni e copertura nuvolosa",
|
||||||
|
"compare_wind_direction_height": "Direzione del vento a {height} m",
|
||||||
|
"compare_standard_preset_description": "Un punto di partenza equilibrato per confrontare i modelli previsionali.",
|
||||||
|
"compare_preset_active": "Attivo",
|
||||||
|
"compare_custom_selection": "Personalizzata · {count} selezionate",
|
||||||
|
"compare_customize_variables": "Personalizza le variabili",
|
||||||
|
"compare_restore_defaults": "Ripristina la selezione standard",
|
||||||
|
"compare_variables_pending": "Selezione delle variabili modificata",
|
||||||
|
"compare_selected_models": "Modelli selezionati",
|
||||||
|
"compare_selection_pending": "Selezione modificata",
|
||||||
|
"compare_apply_selection": "Applica e ricarica i grafici",
|
||||||
|
"compare_discard_changes": "Annulla modifiche",
|
||||||
|
"compare_customize_models": "Personalizza modelli",
|
||||||
|
"compare_edit_models": "Modifica modelli",
|
||||||
|
"compare_edit_variables": "Modifica variabili",
|
||||||
|
"compare_edit_models_description": "Cerca modelli o seleziona intere famiglie di fornitori.",
|
||||||
|
"compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.",
|
||||||
|
"compare_search_models": "Cerca modelli",
|
||||||
|
"compare_search_variables": "Cerca variabili",
|
||||||
|
"compare_show_model_names": "Mostra nomi dei modelli",
|
||||||
|
"compare_hide_model_names": "Nascondi nomi dei modelli",
|
||||||
|
"compare_only_selected": "Solo selezionati",
|
||||||
|
"compare_select_group": "Seleziona tutto",
|
||||||
|
"compare_clear_group": "Deseleziona tutto",
|
||||||
|
"compare_no_matching_options": "Nessuna opzione corrispondente.",
|
||||||
|
"compare_no_models_selected": "Nessun modello selezionato.",
|
||||||
|
"compare_remove_model": "Rimuovi {model}",
|
||||||
|
"compare_timeline_title": "Cronologia del confronto tra modelli",
|
||||||
|
"compare_model_label": "Modello",
|
||||||
|
"page_compare_title": "Confronto dei modelli meteo",
|
||||||
|
"page_compare_description": "Confronta le previsioni orarie di più modelli meteo per qualsiasi località.",
|
||||||
|
"compare_direction_subtitle": "Dispersione della direzione tra {count} modelli · senza media",
|
||||||
|
"compare_scalar_subtitle": "Tra {count} modelli · tratteggiato = media dei modelli",
|
||||||
|
"compare_precipitation_subtitle": "Tra {count} modelli · fascia inferiore = concordanza sulle precipitazioni",
|
||||||
|
"compare_precipitation_agreement": "Concordanza",
|
||||||
|
"compare_precipitation_agreement_tooltip": "{wet}/{available} con precipitazioni · mediana {median} {unit} · intervallo {min}–{max} {unit}",
|
||||||
|
"compare_model_mean": "Media dei modelli",
|
||||||
|
"compare_showing_previous": "Il confronto precedente rimane visibile.",
|
||||||
|
"compare_empty_selection": "Seleziona almeno un modello e una variabile meteo.",
|
||||||
|
"compare_no_data": "Nessuno dei modelli selezionati fornisce dati utilizzabili per questa località e selezione.",
|
||||||
|
"compare_weather_codes_only": "Le condizioni e la copertura nuvolosa sono mostrate nella cronologia in basso. Seleziona un’altra variabile per aggiungere un grafico.",
|
||||||
|
"compare_model_colors": "Colori dei modelli",
|
||||||
|
"compare_group_temperature": "Temperatura e umidità",
|
||||||
|
"compare_group_precipitation": "Precipitazioni e condizioni",
|
||||||
|
"compare_group_clouds": "Pressione e nuvole",
|
||||||
|
"compare_group_wind": "Vento e atmosfera",
|
||||||
|
"compare_group_upper_air": "Temperatura in quota",
|
||||||
|
"compare_many_models_warning": "Sono selezionati {count} modelli. Il confronto può essere più lento e i colori più difficili da distinguere.",
|
||||||
|
"compare_timeline_hint": "Condizioni per modello; lo sfondo dei pittogrammi indica la copertura nuvolosa totale",
|
||||||
|
"compare_timeline_scroll_aria": "Cronologia meteo scorrevole dei modelli",
|
||||||
|
"compare_timeline_caption": "Condizioni meteo orarie per modello di previsione",
|
||||||
|
"ensemble_trimmed": "L'ensemble di questo modello arriva solo a circa {days} giorni, quindi la dispersione è ridotta al periodo disponibile.",
|
||||||
|
"seasonal_explainer_before": "Una previsione stagionale mostra quanto un intero mese potrebbe",
|
||||||
|
"seasonal_explainer_strong": "discostarsi dalla sua normale climatica",
|
||||||
|
"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 1991–2020…",
|
||||||
|
"normal_band_legend": "banda normale = media 1991–2020",
|
||||||
|
"anomaly_vs_normal": "{value} rispetto alla normale",
|
||||||
|
"stat_avg_temperature": "Temperatura media",
|
||||||
|
"stat_mean_temperature": "Temperatura media",
|
||||||
|
"stat_total_precipitation": "Precipitazioni totali",
|
||||||
|
"stat_warmest_day": "Giorno più caldo",
|
||||||
|
"stat_coldest_day": "Giorno più freddo",
|
||||||
|
"historical_daily_heading": "Giornaliero",
|
||||||
|
"historical_daily_hint": "– seleziona un giorno per il dettaglio orario",
|
||||||
|
"historical_full_range": "– intervallo completo",
|
||||||
|
"historical_from": "Dal",
|
||||||
|
"historical_to": "Al",
|
||||||
|
"historical_quick_ranges": "Intervalli rapidi",
|
||||||
|
"historical_last_days": "{days} giorni",
|
||||||
|
"historical_month_last_year": "Questo mese, l'anno scorso",
|
||||||
|
"label_day": "giorno",
|
||||||
|
"label_night": "notte",
|
||||||
|
"daycards_past": "Ultimi 3 giorni",
|
||||||
|
"daycards_load_15": "Carica 15 giorni",
|
||||||
|
"error_technical_details": "Dettagli tecnici",
|
||||||
|
"err_network_title": "Servizio meteo non raggiungibile",
|
||||||
|
"err_network_hint": "Controlla la connessione a internet e riprova.",
|
||||||
|
"err_nodata_title": "Nessun dato per questa località con il modello selezionato",
|
||||||
|
"err_nodata_hint": "I modelli regionali coprono solo la propria area - \"Best match\" sceglie automaticamente un modello adatto.",
|
||||||
|
"err_rejected_title": "Il servizio meteo ha rifiutato la richiesta",
|
||||||
|
"err_rejected_hint": "Prova impostazioni diverse o torna al modello \"Best match\".",
|
||||||
|
"err_generic_title": "Caricamento dei dati meteo non riuscito",
|
||||||
|
"err_generic_hint": "Riprova tra un momento. Se continua, passa al modello \"Best match\".",
|
||||||
|
"maps_iframe_title": "Mappa interattiva di Open-Meteo",
|
||||||
|
"page_maps_title": "Mappa meteo",
|
||||||
|
"page_week_title": "Meteo",
|
||||||
|
"search_favorite_add": "Aggiungi ai preferiti",
|
||||||
|
"search_favorite_remove": "Rimuovi dai preferiti",
|
||||||
|
"supporter_price_per_month": "{amount} / mese",
|
||||||
|
"model_best_match_hint": "Sceglie automaticamente il modello migliore per questa località",
|
||||||
|
"model_updated": "aggiornato {cadence}",
|
||||||
|
"cadence_every_hour": "ogni ora",
|
||||||
|
"cadence_every_hours": "ogni {hours} h",
|
||||||
|
"cadence_daily": "ogni giorno",
|
||||||
|
"cadence_monthly": "ogni mese",
|
||||||
|
"cadence_varies": "variabile",
|
||||||
|
"model_group_automatic": "Automatico",
|
||||||
|
"model_group_reanalysis": "Rianalisi ECMWF",
|
||||||
|
"model_group_regional_reanalysis": "Rianalisi regionale",
|
||||||
|
"wmo_0": "Cielo sereno",
|
||||||
|
"wmo_1": "Prevalentemente sereno",
|
||||||
|
"wmo_2": "Parzialmente nuvoloso",
|
||||||
|
"wmo_3": "Coperto",
|
||||||
|
"wmo_45": "Nebbia",
|
||||||
|
"wmo_48": "Nebbia gelata",
|
||||||
|
"wmo_51": "Pioviggine debole",
|
||||||
|
"wmo_53": "Pioviggine moderata",
|
||||||
|
"wmo_55": "Pioviggine intensa",
|
||||||
|
"wmo_56": "Pioviggine congelantesi debole",
|
||||||
|
"wmo_57": "Pioviggine congelantesi intensa",
|
||||||
|
"wmo_61": "Pioggia debole",
|
||||||
|
"wmo_63": "Pioggia moderata",
|
||||||
|
"wmo_65": "Pioggia forte",
|
||||||
|
"wmo_66": "Pioggia congelantesi debole",
|
||||||
|
"wmo_67": "Pioggia congelantesi forte",
|
||||||
|
"wmo_71": "Nevicata debole",
|
||||||
|
"wmo_73": "Nevicata moderata",
|
||||||
|
"wmo_75": "Nevicata forte",
|
||||||
|
"wmo_77": "Granuli di neve",
|
||||||
|
"wmo_80": "Rovesci deboli",
|
||||||
|
"wmo_81": "Rovesci moderati",
|
||||||
|
"wmo_82": "Rovesci violenti",
|
||||||
|
"wmo_85": "Rovesci di neve deboli",
|
||||||
|
"wmo_86": "Rovesci di neve forti",
|
||||||
|
"wmo_95": "Temporale",
|
||||||
|
"wmo_96": "Temporale con grandine leggera",
|
||||||
|
"wmo_99": "Temporale con grandine forte",
|
||||||
|
"summary_read_more": "Leggi tutto",
|
||||||
|
"summary_read_less": "Mostra meno"
|
||||||
|
}
|
||||||
Generated
+267
-323
@@ -7,13 +7,10 @@
|
|||||||
"": {
|
"": {
|
||||||
"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",
|
||||||
@@ -50,43 +47,6 @@
|
|||||||
"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",
|
||||||
@@ -536,6 +496,64 @@
|
|||||||
"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",
|
||||||
@@ -596,6 +614,32 @@
|
|||||||
"@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",
|
||||||
@@ -606,119 +650,6 @@
|
|||||||
"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",
|
||||||
@@ -751,10 +682,6 @@
|
|||||||
"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",
|
||||||
@@ -865,9 +792,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -885,9 +809,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -905,9 +826,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -925,9 +843,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -945,9 +860,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -965,9 +877,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1054,6 +963,23 @@
|
|||||||
"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",
|
||||||
@@ -1296,9 +1222,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1316,9 +1239,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1336,9 +1256,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1356,9 +1273,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1551,12 +1465,6 @@
|
|||||||
"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",
|
||||||
@@ -2060,6 +1968,13 @@
|
|||||||
"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",
|
||||||
@@ -2158,6 +2073,40 @@
|
|||||||
"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",
|
||||||
@@ -2242,6 +2191,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dedent": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"babel-plugin-macros": "^3.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"babel-plugin-macros": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"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",
|
||||||
@@ -2286,12 +2250,6 @@
|
|||||||
"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",
|
||||||
@@ -2544,6 +2502,20 @@
|
|||||||
"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",
|
||||||
@@ -2740,12 +2712,6 @@
|
|||||||
"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",
|
||||||
@@ -2779,6 +2745,16 @@
|
|||||||
"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",
|
||||||
@@ -2863,6 +2839,13 @@
|
|||||||
"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",
|
||||||
@@ -2904,17 +2887,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-stringify-pretty-compact": {
|
"node_modules/json5": {
|
||||||
"version": "4.0.0",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||||
"license": "MIT"
|
"dev": true,
|
||||||
},
|
"license": "MIT",
|
||||||
"node_modules/kdbush": {
|
"bin": {
|
||||||
"version": "4.1.0",
|
"json5": "lib/cli.js"
|
||||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
},
|
||||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
"engines": {
|
||||||
"license": "ISC"
|
"node": ">=6"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
@@ -2943,6 +2927,16 @@
|
|||||||
"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",
|
||||||
@@ -3100,9 +3094,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3124,9 +3115,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3148,9 +3136,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3172,9 +3157,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3290,40 +3272,6 @@
|
|||||||
"@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",
|
||||||
@@ -3340,15 +3288,6 @@
|
|||||||
"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",
|
||||||
@@ -3376,12 +3315,6 @@
|
|||||||
"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",
|
||||||
@@ -3530,18 +3463,6 @@
|
|||||||
"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",
|
||||||
@@ -3741,12 +3662,6 @@
|
|||||||
"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",
|
||||||
@@ -3863,12 +3778,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -3879,12 +3788,6 @@
|
|||||||
"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",
|
||||||
@@ -3899,15 +3802,6 @@
|
|||||||
"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",
|
||||||
@@ -4055,6 +3949,18 @@
|
|||||||
"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",
|
||||||
@@ -4338,12 +4244,6 @@
|
|||||||
"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",
|
||||||
@@ -4452,6 +4352,22 @@
|
|||||||
"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",
|
||||||
@@ -4462,6 +4378,13 @@
|
|||||||
"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",
|
||||||
@@ -4469,6 +4392,20 @@
|
|||||||
"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",
|
||||||
@@ -4689,6 +4626,13 @@
|
|||||||
"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",
|
||||||
|
|||||||
+1
-4
@@ -19,6 +19,7 @@
|
|||||||
"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",
|
||||||
@@ -53,9 +54,5 @@
|
|||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/project-settings",
|
||||||
|
"baseLocale": "en",
|
||||||
|
"locales": ["en", "de", "es", "fr", "it"],
|
||||||
|
"modules": [
|
||||||
|
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.4.0/dist/index.js",
|
||||||
|
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2.2.6/dist/index.js"
|
||||||
|
],
|
||||||
|
"plugin.inlang.messageFormat": {
|
||||||
|
"pathPattern": "./messages/{locale}.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Regenerates static/data/cities/ - the city lists the "nearby cities" section
|
||||||
|
* on the week page picks from.
|
||||||
|
*
|
||||||
|
* Source: GeoNames (CC BY 4.0) via the cities500 mirror.
|
||||||
|
*
|
||||||
|
* The full list of towns down to 20k inhabitants is ~1 MB, far too much to ship
|
||||||
|
* to a phone for one section, so it is cut into 10x10 degree tiles. A tile
|
||||||
|
* holds every town within NEAR_RADIUS_KM of anywhere inside it, plus the larger
|
||||||
|
* cities within FAR_RADIUS_KM - so one small fetch answers both "what is around
|
||||||
|
* the corner" and "what is the nearest place you would recognise" (which is all
|
||||||
|
* a location in, say, the Australian outback can offer). Tiles overlap by
|
||||||
|
* design; duplicating a few rows is cheaper than a second round trip.
|
||||||
|
*
|
||||||
|
* The ids are GeoNames ids, which is what Open-Meteo's geocoding API returns,
|
||||||
|
* so a row can be turned into a location route without another lookup.
|
||||||
|
*
|
||||||
|
* Run with: node scripts/build-cities.mjs
|
||||||
|
*/
|
||||||
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const SOURCE = 'https://raw.githubusercontent.com/lmfmaier/cities-json/master/cities500.json';
|
||||||
|
|
||||||
|
const MIN_POPULATION = 20_000;
|
||||||
|
/** every city of any size within this range of the tile */
|
||||||
|
const NEAR_RADIUS_KM = 400;
|
||||||
|
/** only cities above FAR_MIN_POPULATION out to here */
|
||||||
|
const FAR_RADIUS_KM = 1500;
|
||||||
|
const FAR_MIN_POPULATION = 300_000;
|
||||||
|
const TILE_DEGREES = 10;
|
||||||
|
|
||||||
|
const outDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'static', 'data', 'cities');
|
||||||
|
|
||||||
|
// CITIES_SOURCE_FILE lets you build from an already-downloaded copy, which is
|
||||||
|
// also the only way to run this behind a proxy that node doesn't pick up.
|
||||||
|
const localSource = process.env.CITIES_SOURCE_FILE;
|
||||||
|
const source = localSource
|
||||||
|
? JSON.parse(await readFile(localSource, 'utf8'))
|
||||||
|
: await fetch(SOURCE).then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`source fetch failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cities = source
|
||||||
|
.map((c) => ({
|
||||||
|
id: Number(c.id),
|
||||||
|
name: c.name,
|
||||||
|
country: c.country,
|
||||||
|
lat: Number(c.lat),
|
||||||
|
lon: Number(c.lon),
|
||||||
|
pop: Number(c.pop ?? 0)
|
||||||
|
}))
|
||||||
|
.filter((c) => Number.isFinite(c.lat) && Number.isFinite(c.lon) && c.pop >= MIN_POPULATION)
|
||||||
|
// biggest first, so a tile that gets truncated keeps the recognisable names
|
||||||
|
.sort((a, b) => b.pop - a.pop);
|
||||||
|
|
||||||
|
const toRad = Math.PI / 180;
|
||||||
|
|
||||||
|
/** Distance from a point to the nearest point of a lat/lon rectangle. */
|
||||||
|
function distanceToTileKm(lat, lon, latMin, latMax, lonMin, lonMax) {
|
||||||
|
const clampedLat = Math.min(latMax, Math.max(latMin, lat));
|
||||||
|
// longitude distance shrinks towards the poles, measured at the closest latitude
|
||||||
|
let dLon = 0;
|
||||||
|
if (lon < lonMin) dLon = lonMin - lon;
|
||||||
|
else if (lon > lonMax) dLon = lon - lonMax;
|
||||||
|
if (dLon > 180) dLon = 360 - dLon;
|
||||||
|
|
||||||
|
const dLat = Math.abs(clampedLat - lat);
|
||||||
|
const lonKm = dLon * 111.32 * Math.cos(clampedLat * toRad);
|
||||||
|
return Math.hypot(dLat * 111.32, lonKm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiles = new Map();
|
||||||
|
for (let latIndex = 0; latIndex < 180 / TILE_DEGREES; latIndex++) {
|
||||||
|
for (let lonIndex = 0; lonIndex < 360 / TILE_DEGREES; lonIndex++) {
|
||||||
|
const latMin = -90 + latIndex * TILE_DEGREES;
|
||||||
|
const lonMin = -180 + lonIndex * TILE_DEGREES;
|
||||||
|
const bounds = [latMin, latMin + TILE_DEGREES, lonMin, lonMin + TILE_DEGREES];
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const c of cities) {
|
||||||
|
const dist = distanceToTileKm(c.lat, c.lon, ...bounds);
|
||||||
|
const inRange =
|
||||||
|
dist <= NEAR_RADIUS_KM || (c.pop >= FAR_MIN_POPULATION && dist <= FAR_RADIUS_KM);
|
||||||
|
if (!inRange) continue;
|
||||||
|
// tuples, not objects: same data, roughly half the bytes
|
||||||
|
rows.push([
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.country,
|
||||||
|
+c.lat.toFixed(3),
|
||||||
|
+c.lon.toFixed(3),
|
||||||
|
Math.round(c.pop / 1000)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (rows.length > 0) tiles.set(`${latIndex}_${lonIndex}`, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(outDir, { recursive: true, force: true });
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
let bytes = 0;
|
||||||
|
for (const [key, rows] of tiles) {
|
||||||
|
const json = JSON.stringify(rows);
|
||||||
|
bytes += json.length;
|
||||||
|
await writeFile(join(outDir, `${key}.json`), json, 'utf8');
|
||||||
|
}
|
||||||
|
// the index tells the client which tiles exist, so an empty ocean tile is a
|
||||||
|
// no-op instead of a 404
|
||||||
|
await writeFile(join(outDir, 'index.json'), JSON.stringify([...tiles.keys()]), 'utf8');
|
||||||
|
|
||||||
|
const largest = Math.max(...[...tiles.values()].map((r) => r.length));
|
||||||
|
console.log(
|
||||||
|
`wrote ${tiles.size} tiles (${(bytes / 1024 / 1024).toFixed(1)} MB total, ` +
|
||||||
|
`avg ${Math.round(bytes / tiles.size / 1024)} KB, largest ${largest} cities) to ${outDir}`
|
||||||
|
);
|
||||||
+6
-1
@@ -1,8 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="%paraglide.lang%">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<!-- Declared in the shell, not in the layout head: the SPA fallback page
|
||||||
|
(404.html) ships no rendered head, so without this the browser falls
|
||||||
|
back to requesting /favicon.ico and takes a 404 on every load of an
|
||||||
|
unprerendered URL. -->
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<script>
|
<script>
|
||||||
// apply the persisted theme before first paint to avoid a flash
|
// apply the persisted theme before first paint to avoid a flash
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||||
|
|
||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs for every prerendered page, so the HTML written to disk is already in the
|
||||||
|
* right language and carries a matching `<html lang>` - the client never has to
|
||||||
|
* repaint the page into its locale.
|
||||||
|
*/
|
||||||
|
export const handle: Handle = ({ event, resolve }) =>
|
||||||
|
paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||||
|
event.request = request;
|
||||||
|
return resolve(event, {
|
||||||
|
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { deLocalizeUrl } from '$lib/paraglide/runtime';
|
||||||
|
|
||||||
|
import type { Reroute } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locale lives in the URL (`/de/weather/week/`), but the routes on disk are
|
||||||
|
* language-neutral. Stripping the prefix here lets one route tree serve every
|
||||||
|
* language - on the server, during prerendering and on client-side navigation.
|
||||||
|
*/
|
||||||
|
export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;
|
||||||
@@ -18,17 +18,21 @@
|
|||||||
/>
|
/>
|
||||||
-->
|
-->
|
||||||
<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';
|
type: 'line' | 'bar' | 'point';
|
||||||
/** 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
|
||||||
@@ -62,8 +66,28 @@
|
|||||||
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 puffy cloud band hanging from the top instead of a line */
|
/** Render as a soft cloud band instead of a line (see `cloudLayer`) */
|
||||||
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 {
|
||||||
@@ -93,6 +117,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Register a non-chart surface that participates in synchronized interactions. */
|
||||||
|
export function registerGroupMember(name: string): () => void {
|
||||||
|
acquireGroup(name);
|
||||||
|
let registered = true;
|
||||||
|
return () => {
|
||||||
|
if (!registered) return;
|
||||||
|
registered = false;
|
||||||
|
releaseGroup(name);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drive the shared crosshair of a chart group from the outside (e.g. hovering
|
* 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
|
||||||
@@ -100,7 +135,16 @@
|
|||||||
*/
|
*/
|
||||||
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;
|
if (state && state.hover !== time) state.hover = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the synchronized visible time range for a registered chart group. */
|
||||||
|
export function setGroupRange(
|
||||||
|
name: string,
|
||||||
|
range: { start: number; end: number } | null
|
||||||
|
): void {
|
||||||
|
const state = groups[name];
|
||||||
|
if (state) state.range = range;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current shared zoom range of a group (null = full range), reactive. */
|
/** Current shared zoom range of a group (null = full range), reactive. */
|
||||||
@@ -122,6 +166,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -142,6 +187,8 @@
|
|||||||
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 */
|
||||||
@@ -152,8 +199,18 @@
|
|||||||
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) */
|
||||||
@@ -168,6 +225,10 @@
|
|||||||
/** 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 */
|
||||||
@@ -178,6 +239,8 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -190,29 +253,44 @@
|
|||||||
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)
|
||||||
@@ -223,8 +301,13 @@
|
|||||||
// 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;
|
||||||
// Puffy cloud band: 100% cover hangs 40px from the top of the plot
|
// Total cloud cover: 100% reaches this far down from the top of the plot.
|
||||||
const CLOUD_BAND_MAX = 40;
|
const CLOUD_BAND_MAX = 48;
|
||||||
|
// 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 {
|
||||||
@@ -250,6 +333,97 @@
|
|||||||
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();
|
||||||
@@ -267,6 +441,10 @@
|
|||||||
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);
|
||||||
@@ -294,21 +472,39 @@
|
|||||||
|
|
||||||
// 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).
|
||||||
let isNarrow = $derived(width > 0 && width < 520);
|
// Compact gutters (small axis padding, short icon rows) apply to phones AND
|
||||||
let padLeft = $derived(isNarrow ? 26 : 60);
|
// tablets: below `lg` the page is edge-to-edge, so wide desktop-style axis
|
||||||
|
// 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(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
|
let padRight = $derived(
|
||||||
|
plotInsetRight ?? (isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20)
|
||||||
|
);
|
||||||
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
// 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(Math.max(ownIconRows, reserveTopRows));
|
let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows));
|
||||||
// tighter top/bottom gutters on mobile so charts don't waste vertical space
|
// tighter top/bottom gutters on mobile so charts don't waste vertical space
|
||||||
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
|
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
|
||||||
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
|
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 22 : 28) + iconRows * iconRowH);
|
||||||
let padBottom = $derived(isNarrow ? 24 : PAD_BOTTOM);
|
let agreementStripOffset = $derived(
|
||||||
|
agreementStrip ? AGREEMENT_STRIP_HEIGHT + AGREEMENT_STRIP_GAP + 2 : 0
|
||||||
|
);
|
||||||
|
let padBottom = $derived((isNarrow ? 24 : PAD_BOTTOM) + agreementStripOffset);
|
||||||
let plotW = $derived(Math.max(1, width - padLeft - padRight));
|
let 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));
|
||||||
|
|
||||||
@@ -353,16 +549,38 @@
|
|||||||
return [lo, hi];
|
return [lo, hi];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScale(lo: number, hi: number, loFixed: boolean, hiFixed: boolean): Scale {
|
function buildScale(
|
||||||
|
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);
|
||||||
const min = loFixed ? lo : Math.floor(lo / step) * step;
|
// Padded axes (e.g. temperature) may end on HALF steps — 5° when ticks
|
||||||
const max = hiFixed ? hi : Math.ceil(hi / step) * step;
|
// are every 10° — so the requested margin isn't inflated to a whole 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 => {
|
||||||
const [dLo, dHi] = dataExtent('left', zeroBaseLeft);
|
let [dLo, dHi] = dataExtent('left', zeroBaseLeft);
|
||||||
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
|
// requested breathing room around the data (e.g. temperature): at least
|
||||||
|
// 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 => {
|
||||||
@@ -400,8 +618,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setHover(time: number | null): void {
|
function setHover(time: number | null): void {
|
||||||
if (groupState) groupState.hover = time;
|
if (groupState) {
|
||||||
else localHover = time;
|
if (groupState.hover !== time) groupState.hover = time;
|
||||||
|
} else if (localHover !== time) {
|
||||||
|
localHover = time;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRange(start: number, end: number): void {
|
function applyRange(start: number, end: number): void {
|
||||||
@@ -433,7 +654,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 Map<string, Promise<HTMLImageElement>>();
|
const iconImageCache = new SvelteMap<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);
|
||||||
@@ -569,7 +790,11 @@
|
|||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
ctx.fillStyle = muted;
|
ctx.fillStyle = muted;
|
||||||
ctx.globalAlpha = 0.8;
|
ctx.globalAlpha = 0.8;
|
||||||
ctx.fillText('Weather data by Open-Meteo · visualisation by Drizz.li', width - 6, totalH - 5);
|
ctx.fillText(
|
||||||
|
`${m.footer_data_by()} Open-Meteo · ${m.chart_credit_viz()} Drizz.li`,
|
||||||
|
width - 6,
|
||||||
|
totalH - 5
|
||||||
|
);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,12 +847,23 @@
|
|||||||
: `${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) ─────────────────────────────────
|
||||||
|
|
||||||
@@ -839,7 +1075,13 @@
|
|||||||
ctx.font = font;
|
ctx.font = font;
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
for (let v = leftScale.min; v <= leftScale.max + leftScale.step / 2; v += leftScale.step) {
|
const leftTicks =
|
||||||
|
yTicks ??
|
||||||
|
Array.from(
|
||||||
|
{ length: Math.floor((leftScale.max - firstTick(leftScale)) / leftScale.step) + 1 },
|
||||||
|
(_, i) => firstTick(leftScale) + i * leftScale.step
|
||||||
|
);
|
||||||
|
for (const v of leftTicks) {
|
||||||
const y = yPix(v, 'left');
|
const y = yPix(v, 'left');
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
@@ -848,7 +1090,13 @@
|
|||||||
ctx.lineTo(plotRight, y);
|
ctx.lineTo(plotRight, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
|
ctx.fillText(
|
||||||
|
compactYAxis
|
||||||
|
? v.toFixed(tickDecimals(leftScale.step))
|
||||||
|
: (yTickFormat?.(v) ?? v.toFixed(tickDecimals(leftScale.step))),
|
||||||
|
padLeft - 8,
|
||||||
|
y
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right axis labels in the gutter (desktop only). On mobile there is no
|
// Right axis labels in the gutter (desktop only). On mobile there is no
|
||||||
@@ -858,7 +1106,7 @@
|
|||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
for (
|
for (
|
||||||
let v = rightScale.min;
|
let v = firstTick(rightScale);
|
||||||
v <= rightScale.max + rightScale.step / 2;
|
v <= rightScale.max + rightScale.step / 2;
|
||||||
v += rightScale.step
|
v += rightScale.step
|
||||||
) {
|
) {
|
||||||
@@ -889,7 +1137,7 @@
|
|||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
}
|
}
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(tick.label, x, plotBottom + 7);
|
ctx.fillText(tick.label, x, plotBottom + agreementStripOffset + 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Series (clipped to the plot area)
|
// Series (clipped to the plot area)
|
||||||
@@ -900,23 +1148,104 @@
|
|||||||
|
|
||||||
const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR;
|
const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR;
|
||||||
|
|
||||||
// Puffy cloud band: overlapping circles hang from the top of the plot,
|
// Cloud bands: cover drives how far the band reaches into its slot, traced
|
||||||
// each reaching down by (cover/100) × CLOUD_BAND_MAX. Neighbouring puffs
|
// as a monotone-interpolated curve so the silhouette flows instead of
|
||||||
// merge into a soft, rounded silhouette.
|
// stepping hour to hour. Both gradients run from near-transparent at the
|
||||||
|
// 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) {
|
||||||
ctx.save();
|
const slotTop = padTop + cloudSlotOffset(s);
|
||||||
ctx.fillStyle = s.color;
|
const layered = s.cloudLayer != null;
|
||||||
ctx.globalAlpha = 0.5;
|
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++) {
|
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];
|
const t = timestamps[i];
|
||||||
if (t < viewStart - interval || t > viewEnd + interval) continue;
|
if (t < viewStart - interval * 2 || t > viewEnd + interval * 2) continue;
|
||||||
const r = (Math.min(100, v) / 100) * CLOUD_BAND_MAX;
|
const v = s.data[i];
|
||||||
if (r < 1) continue;
|
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.beginPath();
|
||||||
|
traceMonotone(ctx, bottom);
|
||||||
|
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();
|
||||||
|
|
||||||
|
// The layers are gradient only - an outline would fight the soft mass
|
||||||
|
// they are meant to look like.
|
||||||
|
if (edge) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(xPix(t), padTop, r, 0, Math.PI * 2);
|
traceMonotone(ctx, bottom);
|
||||||
ctx.fill();
|
ctx.strokeStyle = edge;
|
||||||
|
ctx.lineWidth = 1.25;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
@@ -945,6 +1274,24 @@
|
|||||||
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.
|
||||||
@@ -1153,7 +1500,7 @@
|
|||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
for (
|
for (
|
||||||
let v = rightScale.min;
|
let v = firstTick(rightScale);
|
||||||
v <= rightScale.max + rightScale.step / 2;
|
v <= rightScale.max + rightScale.step / 2;
|
||||||
v += rightScale.step
|
v += rightScale.step
|
||||||
) {
|
) {
|
||||||
@@ -1166,9 +1513,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current time marker
|
// Current time marker. Reading the shared clock (rather than Date.now())
|
||||||
|
// makes the enclosing draw effect re-run every minute, so the line moves.
|
||||||
if (showNow) {
|
if (showNow) {
|
||||||
const now = Date.now() / 1000;
|
const now = clock.current.getTime() / 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;
|
||||||
@@ -1180,24 +1528,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crosshair (snapped to the nearest timestamp)
|
ctx.restore();
|
||||||
if (hoverIdx >= 0) {
|
|
||||||
const t = timestamps[hoverIdx];
|
if (agreementStrip) {
|
||||||
if (t >= viewStart && t <= viewEnd) {
|
const stripTop = plotBottom + AGREEMENT_STRIP_GAP;
|
||||||
const x = xPix(t);
|
ctx.save();
|
||||||
ctx.strokeStyle = textColor;
|
ctx.beginPath();
|
||||||
ctx.lineWidth = 1;
|
ctx.rect(padLeft, stripTop, plotW, AGREEMENT_STRIP_HEIGHT);
|
||||||
ctx.setLineDash([4, 4]);
|
ctx.clip();
|
||||||
ctx.beginPath();
|
for (let index = 0; index < timestamps.length; index++) {
|
||||||
ctx.moveTo(x, padTop);
|
const point = agreementStrip.points[index];
|
||||||
ctx.lineTo(x, plotBottom);
|
if (!point || point.availableCount === 0) continue;
|
||||||
ctx.stroke();
|
const time = timestamps[index];
|
||||||
ctx.setLineDash([]);
|
if (time < viewStart - interval || time > viewEnd + interval) continue;
|
||||||
|
const left = Math.max(padLeft, xPix(time - interval / 2));
|
||||||
|
const right = Math.min(plotRight, xPix(time + interval / 2));
|
||||||
|
if (right <= left) continue;
|
||||||
|
ctx.fillStyle = agreementFillColor(point, textColor);
|
||||||
|
ctx.fillRect(left, stripTop, Math.max(1, right - left + 0.5), AGREEMENT_STRIP_HEIGHT);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.strokeStyle = withAlpha(gridColor, 0.8);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(padLeft, stripTop + 0.5, plotW, AGREEMENT_STRIP_HEIGHT - 1);
|
||||||
|
if (!compactYAxis) {
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.font = '600 9px system-ui, sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(
|
||||||
|
agreementStrip.label,
|
||||||
|
padLeft - 6,
|
||||||
|
stripTop + AGREEMENT_STRIP_HEIGHT / 2,
|
||||||
|
Math.max(24, padLeft - 10)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Axis unit labels
|
// Axis unit labels
|
||||||
ctx.font = font;
|
ctx.font = font;
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
@@ -1289,13 +1657,47 @@
|
|||||||
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 updateHover = (e: PointerEvent): void => {
|
const scheduleHoverFrame = (): void => {
|
||||||
const t = pixToTime(localX(e));
|
if (hoverFrame !== 0) return;
|
||||||
setHover(t >= viewStart && t <= viewEnd ? t : null);
|
hoverFrame = requestAnimationFrame(() => {
|
||||||
|
hoverFrame = 0;
|
||||||
|
const action = queuedHoverAction;
|
||||||
|
queuedHoverAction = 'none';
|
||||||
|
if (action === 'clear') {
|
||||||
|
setHover(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action !== 'position') return;
|
||||||
|
|
||||||
|
// Geometry reads and timestamp lookup happen once per animation frame,
|
||||||
|
// even when a high-polling pointer emits many events between frames.
|
||||||
|
const t = pixToTime(queuedClientX - el.getBoundingClientRect().left);
|
||||||
|
const time =
|
||||||
|
t >= viewStart && t <= viewEnd && timestamps.length > 0
|
||||||
|
? timestamps[nearestIndex(timestamps, t)]
|
||||||
|
: null;
|
||||||
|
setHover(time);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleHoverPosition = (clientX: number): void => {
|
||||||
|
if (announceHover) announceHover = false;
|
||||||
|
queuedClientX = clientX;
|
||||||
|
queuedHoverAction = 'position';
|
||||||
|
scheduleHoverFrame();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleHoverClear = (): void => {
|
||||||
|
if (announceHover) announceHover = false;
|
||||||
|
queuedHoverAction = 'clear';
|
||||||
|
scheduleHoverFrame();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerDown = (e: PointerEvent): void => {
|
const onPointerDown = (e: PointerEvent): void => {
|
||||||
@@ -1307,7 +1709,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';
|
||||||
setHover(null);
|
scheduleHoverClear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,7 +1749,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 };
|
||||||
setHover(null);
|
scheduleHoverClear();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1377,7 +1779,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pointers.size <= 1) updateHover(e);
|
if (pointers.size <= 1) scheduleHoverPosition(e.clientX);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerUp = (e: PointerEvent): void => {
|
const onPointerUp = (e: PointerEvent): void => {
|
||||||
@@ -1396,11 +1798,11 @@
|
|||||||
touchStart = null;
|
touchStart = null;
|
||||||
gesture = 'none';
|
gesture = 'none';
|
||||||
}
|
}
|
||||||
if (e.pointerType !== 'mouse') setHover(null);
|
if (e.pointerType !== 'mouse') scheduleHoverClear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerLeave = (): void => {
|
const onPointerLeave = (): void => {
|
||||||
if (pointers.size === 0) setHover(null);
|
if (pointers.size === 0) scheduleHoverClear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onWheel = (e: WheelEvent): void => {
|
const onWheel = (e: WheelEvent): void => {
|
||||||
@@ -1434,6 +1836,7 @@
|
|||||||
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);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1441,6 +1844,29 @@
|
|||||||
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}">
|
||||||
@@ -1450,7 +1876,16 @@
|
|||||||
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}
|
||||||
@@ -1459,7 +1894,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}px"
|
style:height="{plotH + agreementStripOffset}px"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1487,7 +1922,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Wind-direction arrows: a matching band -->
|
<!-- Wind-direction arrows: a matching band -->
|
||||||
{#if visibleWindArrows.length > 0}
|
{#if visibleWindArrows.length > 0}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute z-10 overflow-hidden rounded-t-lg border border-border/60 bg-muted/30"
|
class="pointer-events-none absolute z-10 overflow-hidden rounded-t-lg border border-border/60 bg-muted/30"
|
||||||
style:left="{iconBandLeft}px"
|
style:left="{iconBandLeft}px"
|
||||||
@@ -1510,9 +1945,20 @@
|
|||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if tooltipVisible}
|
<!-- Hover is intentionally a DOM overlay: moving the crosshair must not
|
||||||
|
repaint the expensive base canvas or every synchronized chart. -->
|
||||||
|
{#if crosshairVisible}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute left-0 z-[15] border-l border-dashed border-muted-foreground/70 will-change-transform"
|
||||||
|
style:transform="translateX({tooltipX}px)"
|
||||||
|
style:top="{padTop}px"
|
||||||
|
style:height="{plotH + agreementStripOffset}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if tooltipVisible}
|
||||||
<div
|
<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"
|
||||||
style:top="{padTop + 8}px"
|
style:top="{padTop + 8}px"
|
||||||
@@ -1550,7 +1996,7 @@
|
|||||||
? 'opacity-40'
|
? 'opacity-40'
|
||||||
: ''}"
|
: ''}"
|
||||||
onclick={() => toggleSeries(s.name)}
|
onclick={() => toggleSeries(s.name)}
|
||||||
title="Toggle {s.name}"
|
title={m.chart_toggle_series({ series: 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"
|
||||||
@@ -1569,12 +2015,13 @@
|
|||||||
<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"
|
||||||
>
|
>
|
||||||
Weather data by <a
|
{m.footer_data_by()}
|
||||||
|
<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
|
||||||
>, visualisation by
|
>, {m.chart_credit_viz()}
|
||||||
<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"
|
||||||
|
|||||||
+9
-10
@@ -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', 'inch', 'MJ/m²']);
|
const COLUMN_UNITS = new Set(['mm', 'cm', 'in', 'inch', 'MJ/m²']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the given unit should be rendered as a bar chart.
|
* 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);
|
return COLUMN_UNITS.has(unit.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Data Processing Helpers ─────────────────────────────────────────────────
|
// ─── Data Processing Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AverageResult {
|
export interface AverageResult {
|
||||||
average: number[];
|
average: (number | null)[];
|
||||||
averageCount: number[];
|
averageCount: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export function calculateAverage(
|
|||||||
variable: string,
|
variable: string,
|
||||||
timeLength: number
|
timeLength: number
|
||||||
): AverageResult {
|
): AverageResult {
|
||||||
const average = new Array<number>(timeLength).fill(0);
|
const totals = new Array<number>(timeLength).fill(0);
|
||||||
const averageCount = new Array<number>(timeLength).fill(0);
|
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,18 +71,17 @@ 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)) {
|
||||||
average[index] += val;
|
if (index >= timeLength) continue;
|
||||||
|
totals[index] += val;
|
||||||
averageCount[index]++;
|
averageCount[index]++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize average values
|
// Finalize average values
|
||||||
for (let i = 0; i < timeLength; i++) {
|
const average = totals.map((total, i) =>
|
||||||
if (averageCount[i] > 0) {
|
averageCount[i] > 0 ? Math.round((total / averageCount[i]) * 10) / 10 : null
|
||||||
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { average, averageCount };
|
return { average, averageCount };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,16 @@
|
|||||||
export {
|
export {
|
||||||
default as CanvasChart,
|
default as CanvasChart,
|
||||||
setGroupHover,
|
setGroupHover,
|
||||||
|
setGroupRange,
|
||||||
|
registerGroupMember,
|
||||||
groupRange,
|
groupRange,
|
||||||
groupHover
|
groupHover
|
||||||
} from './CanvasChart.svelte';
|
} from './CanvasChart.svelte';
|
||||||
export type { ChartSeries } from './CanvasChart.svelte';
|
export type {
|
||||||
|
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,6 +18,8 @@
|
|||||||
<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 ──────────────────────────────────────────────────────────────────
|
||||||
@@ -93,7 +95,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">Loading charts...</span>
|
<span class="sr-only">{m.charts_loading()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,12 +18,15 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</ChartToolbar>
|
</ChartToolbar>
|
||||||
-->
|
-->
|
||||||
<script module lang="ts">
|
|
||||||
export type { ExportableChart } from './downloadChartsPng';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ChartDownloadOptions,
|
||||||
|
type ChartExportItem,
|
||||||
|
type ExportableChart,
|
||||||
|
downloadChartsPng
|
||||||
|
} from './downloadChartsPng';
|
||||||
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -31,9 +34,11 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Chart components available for download (undefined entries are skipped) */
|
/** Chart components available for download (undefined entries are skipped) */
|
||||||
charts?: Array<ExportableChart | undefined | null>;
|
charts?: Array<ChartExportItem | ExportableChart | undefined | null>;
|
||||||
/** Base file name for downloaded images (without extension) */
|
/** 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.) */
|
||||||
@@ -43,6 +48,7 @@
|
|||||||
let {
|
let {
|
||||||
charts = [],
|
charts = [],
|
||||||
fileName = 'drizzli-chart',
|
fileName = 'drizzli-chart',
|
||||||
|
exportOptions,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
controls
|
controls
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -53,7 +59,9 @@
|
|||||||
|
|
||||||
// ─── Computed ───────────────────────────────────────────────────────────────
|
// ─── Computed ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let hasCharts = $derived(charts.some((chart) => chart != null));
|
let hasCharts = $derived(
|
||||||
|
charts.some((item) => item != null && ('chart' in item ? item.chart != null : true))
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Download ───────────────────────────────────────────────────────────────
|
// ─── Download ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -61,7 +69,7 @@
|
|||||||
if (!hasCharts || downloading) return;
|
if (!hasCharts || downloading) return;
|
||||||
downloading = true;
|
downloading = true;
|
||||||
try {
|
try {
|
||||||
await downloadChartsPng(charts, fileName);
|
await downloadChartsPng(charts, fileName, exportOptions);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
downloading = false;
|
downloading = false;
|
||||||
@@ -87,7 +95,7 @@
|
|||||||
class="toolbar-btn"
|
class="toolbar-btn"
|
||||||
disabled={!hasCharts || downloading}
|
disabled={!hasCharts || downloading}
|
||||||
onclick={handleDownload}
|
onclick={handleDownload}
|
||||||
title="Download meteogram as PNG image"
|
title={m.chart_download()}
|
||||||
>
|
>
|
||||||
{#if downloading}
|
{#if downloading}
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ 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();
|
||||||
@@ -47,18 +58,59 @@ 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.map(toItem).filter((it) => it.chart != null);
|
const items = charts
|
||||||
|
.map(toItem)
|
||||||
|
.filter(
|
||||||
|
(it): it is ChartExportItem & { chart: ExportableChart } =>
|
||||||
|
typeof it.chart?.getExportImage === 'function'
|
||||||
|
);
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
|
|
||||||
const canvases = (
|
// A supplementary panel must never prevent the remaining charts from being
|
||||||
await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title })))
|
// downloaded. Invalid adapters are filtered above; rejected renders are
|
||||||
).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0);
|
// isolated here instead of aborting the complete Promise.all chain.
|
||||||
|
const rendered = await Promise.allSettled(
|
||||||
|
items.map((it) => it.chart.getExportImage({ title: it.title }))
|
||||||
|
);
|
||||||
|
const canvases = rendered.flatMap((result) =>
|
||||||
|
result.status === 'fulfilled' &&
|
||||||
|
result.value != null &&
|
||||||
|
result.value.width > 0 &&
|
||||||
|
result.value.height > 0
|
||||||
|
? [result.value]
|
||||||
|
: []
|
||||||
|
);
|
||||||
if (canvases.length === 0) return;
|
if (canvases.length === 0) return;
|
||||||
|
|
||||||
const maxWidth = Math.max(...canvases.map((c) => c.width));
|
const maxWidth = Math.max(...canvases.map((c) => c.width));
|
||||||
const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const padding = 12 * dpr;
|
||||||
|
const gap = 14 * dpr;
|
||||||
|
const rowHeight = 22 * dpr;
|
||||||
|
const measure = document.createElement('canvas').getContext('2d');
|
||||||
|
const legendRows: ExportLegendItem[][] = [];
|
||||||
|
if (measure && options.legend?.length) {
|
||||||
|
measure.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
|
||||||
|
let row: ExportLegendItem[] = [];
|
||||||
|
let rowWidth = 0;
|
||||||
|
for (const item of options.legend) {
|
||||||
|
const width = 18 * dpr + measure.measureText(item.name).width;
|
||||||
|
if (row.length > 0 && rowWidth + gap + width > maxWidth - padding * 2) {
|
||||||
|
legendRows.push(row);
|
||||||
|
row = [];
|
||||||
|
rowWidth = 0;
|
||||||
|
}
|
||||||
|
row.push(item);
|
||||||
|
rowWidth += (rowWidth > 0 ? gap : 0) + width;
|
||||||
|
}
|
||||||
|
if (row.length > 0) legendRows.push(row);
|
||||||
|
}
|
||||||
|
const titleHeight = options.title ? 34 * dpr : 0;
|
||||||
|
const legendHeight = legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0;
|
||||||
|
const totalHeight = titleHeight + canvases.reduce((sum, c) => sum + c.height, 0) + legendHeight;
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = maxWidth;
|
canvas.width = maxWidth;
|
||||||
@@ -70,11 +122,54 @@ 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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,10 @@
|
|||||||
|
|
||||||
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 { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
|
export {
|
||||||
|
downloadChartsPng,
|
||||||
|
type ChartDownloadOptions,
|
||||||
|
type ChartExportItem,
|
||||||
|
type ExportableChart,
|
||||||
|
type ExportLegendItem
|
||||||
|
} from './downloadChartsPng';
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { LOCALE_LABELS, LOCALE_LIST } from '$lib/i18n';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
import { type Locale, getLocale, localizeHref } from '$lib/paraglide/runtime';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Called after a language is picked (used to close the menu around it). */
|
||||||
|
onSelect?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSelect }: Props = $props();
|
||||||
|
|
||||||
|
let current = $derived.by(() => {
|
||||||
|
// re-read on navigation: the URL is what decides the locale
|
||||||
|
void $page.url.pathname;
|
||||||
|
return getLocale();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The current page, in another language. */
|
||||||
|
function switchTo(locale: Locale): string {
|
||||||
|
return localizeHref($page.url.pathname + $page.url.search, { locale });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
{m.language_label()}
|
||||||
|
</span>
|
||||||
|
<!-- Plain links, not buttons: the locale lives in the URL, so switching
|
||||||
|
language is a navigation. It also means each option is shareable and
|
||||||
|
crawlable. -->
|
||||||
|
<div class="grid grid-cols-2 gap-1">
|
||||||
|
{#each LOCALE_LIST as locale (locale)}
|
||||||
|
{@const active = current === locale}
|
||||||
|
<a
|
||||||
|
href={switchTo(locale)}
|
||||||
|
hreflang={locale}
|
||||||
|
data-sveltekit-reload
|
||||||
|
class="cursor-pointer rounded-md px-2 py-1.5 text-center text-[13px] font-semibold transition-colors {active
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:text-foreground'}"
|
||||||
|
aria-current={active ? 'true' : undefined}
|
||||||
|
onclick={onSelect}
|
||||||
|
>
|
||||||
|
{LOCALE_LABELS[locale]}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import LanguageOptions from '$lib/components/language-options.svelte';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
import { getLocale } from '$lib/paraglide/runtime';
|
||||||
|
|
||||||
|
// the URL decides the locale, so re-read it on navigation
|
||||||
|
let current = $derived.by(() => {
|
||||||
|
void $page.url.pathname;
|
||||||
|
return getLocale();
|
||||||
|
});
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger
|
||||||
|
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||||
|
aria-label={m.language_label()}
|
||||||
|
title={m.language_label()}
|
||||||
|
>
|
||||||
|
<!-- globe -->
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M3 12h18M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18Z" />
|
||||||
|
</svg>
|
||||||
|
<span class="uppercase">{current}</span>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content align="end" class="w-56 border-border">
|
||||||
|
<LanguageOptions onSelect={() => (open = false)} />
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { getLocale } from '$lib/paraglide/runtime';
|
||||||
|
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the content component for the current locale.
|
||||||
|
*
|
||||||
|
* Long-form pages (about, imprint, privacy, terms) are kept as one component
|
||||||
|
* per language rather than as message strings: the prose is full of inline
|
||||||
|
* links, lists and headings, and splitting that into placeholders makes both
|
||||||
|
* the source and the translations harder to read and to keep correct.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
variants: Record<string, Component>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { variants }: Props = $props();
|
||||||
|
|
||||||
|
let Content = $derived.by(() => {
|
||||||
|
// the URL decides the locale, so re-resolve on navigation
|
||||||
|
void $page.url.pathname;
|
||||||
|
return variants[getLocale()] ?? variants.en;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Content />
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
import { Input } from '$lib/components/ui/input';
|
import { 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...';
|
||||||
|
|
||||||
@@ -48,6 +50,12 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop a single entry from the recent list (favourites are unaffected). */
|
||||||
|
function removeRecent(loc: GeoLocation) {
|
||||||
|
const key = locationKey(loc);
|
||||||
|
storedRecentLocations.update((list) => list.filter((l) => locationKey(l) !== key));
|
||||||
|
}
|
||||||
|
|
||||||
function toggleFavorite(loc: GeoLocation) {
|
function toggleFavorite(loc: GeoLocation) {
|
||||||
const key = locationKey(loc);
|
const key = locationKey(loc);
|
||||||
storedFavoriteLocations.update((list) =>
|
storedFavoriteLocations.update((list) =>
|
||||||
@@ -126,7 +134,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet locationRow(location: GeoLocation)}
|
{#snippet locationRow(location: GeoLocation, removable: boolean)}
|
||||||
{@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"
|
||||||
@@ -155,8 +163,8 @@
|
|||||||
? 'text-amber-500'
|
? 'text-amber-500'
|
||||||
: 'text-muted-foreground/50'}"
|
: 'text-muted-foreground/50'}"
|
||||||
onclick={() => toggleFavorite(location)}
|
onclick={() => toggleFavorite(location)}
|
||||||
aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
|
aria-label={fav ? m.search_favorite_remove() : m.search_favorite_add()}
|
||||||
title={fav ? 'Remove from favorites' : 'Add to favorites'}
|
title={fav ? m.search_favorite_remove() : m.search_favorite_add()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -172,6 +180,18 @@
|
|||||||
/>
|
/>
|
||||||
</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}
|
||||||
|
|
||||||
@@ -213,7 +233,7 @@
|
|||||||
class="h-9"
|
class="h-9"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
aria-label="Search Location"
|
aria-label={m.search_aria()}
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
bind:ref={searchInputEl}
|
bind:ref={searchInputEl}
|
||||||
/>
|
/>
|
||||||
@@ -222,7 +242,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
class="h-9 px-2.5"
|
class="h-9 px-2.5"
|
||||||
title="Use GPS Location"
|
title={m.search_gps()}
|
||||||
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">
|
||||||
@@ -248,7 +268,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">Searching...</span>
|
<span class="text-sm text-muted-foreground">{m.search_searching()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:then results}
|
{:then results}
|
||||||
@@ -258,11 +278,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"
|
||||||
>
|
>
|
||||||
Favorites
|
{m.search_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)}
|
{@render locationRow(loc, false)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -272,11 +292,11 @@
|
|||||||
? 'mt-3'
|
? 'mt-3'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
Recent
|
{m.search_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)}
|
{@render locationRow(loc, true)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -298,14 +318,14 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs">
|
<span class="text-xs">
|
||||||
Start typing to search or use GPS to detect your position
|
{m.search_hint()}
|
||||||
</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)}
|
{@render locationRow(location, false)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if results.results}
|
{:else if results.results}
|
||||||
@@ -318,7 +338,7 @@
|
|||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Description>No locations found</Alert.Description>
|
<Alert.Description>{m.search_no_results()}</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{/if}
|
{/if}
|
||||||
{:catch error}
|
{:catch error}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
import LogoMark from './logo-mark.svelte';
|
||||||
|
|
||||||
|
// A hand-picked set of the prerendered city pages (see
|
||||||
|
// routes/weather/locations/city-names100.json) — instant navigation targets
|
||||||
|
// that double as SEO entry points.
|
||||||
|
const popularCities: { slug: string; label: string }[] = [
|
||||||
|
{ slug: 'london', label: 'London' },
|
||||||
|
{ slug: 'tokyo', label: 'Tokyo' },
|
||||||
|
{ slug: 'berlin', label: 'Berlin' },
|
||||||
|
{ slug: 'sydney', label: 'Sydney' },
|
||||||
|
{ slug: 'singapore', label: 'Singapore' },
|
||||||
|
{ slug: 'dubai', label: 'Dubai' },
|
||||||
|
{ slug: 'los-angeles', label: 'Los Angeles' },
|
||||||
|
{ slug: 'hong-kong', label: 'Hong Kong' },
|
||||||
|
{ slug: 'istanbul', label: 'Istanbul' },
|
||||||
|
{ slug: 'seoul', label: 'Seoul' },
|
||||||
|
{ slug: 'mexico-city', label: 'Mexico City' },
|
||||||
|
{ slug: 'sao-paulo', label: 'São Paulo' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const forecastLinks = [
|
||||||
|
{ href: href('/weather/week'), label: m.nav_week() },
|
||||||
|
{ href: href('/weather/compare'), label: m.nav_compare() },
|
||||||
|
{ href: href('/weather/14-day'), label: m.nav_14day() },
|
||||||
|
{ href: href('/weather/seasonal'), label: m.page_seasonal_subtitle() },
|
||||||
|
{ href: href('/weather/historical'), label: m.page_historical_subtitle() },
|
||||||
|
{ href: href('/weather/maps'), label: m.nav_maps() }
|
||||||
|
];
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="mt-16 border-t border-border bg-card/60">
|
||||||
|
<div class="mx-auto w-full max-w-[1536px] px-4 py-10 lg:px-8">
|
||||||
|
<div class="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="max-w-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
<LogoMark />
|
||||||
|
</span>
|
||||||
|
<span class="text-lg font-bold tracking-tight">Drizz.li</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{m.footer_tagline()}
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-xs text-muted-foreground">
|
||||||
|
{m.footer_data_by()}
|
||||||
|
<a
|
||||||
|
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href="https://open-meteo.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">Open-Meteo</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forecasts -->
|
||||||
|
<nav aria-label={m.footer_forecasts()}>
|
||||||
|
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||||
|
{m.footer_forecasts()}
|
||||||
|
</h3>
|
||||||
|
<ul class="mt-3 flex flex-col gap-2 text-sm">
|
||||||
|
{#each forecastLinks as link (link.href)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={link.href}>{link.label}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Popular locations (spans two columns of links) -->
|
||||||
|
<nav aria-label={m.footer_popular()} class="lg:col-span-2">
|
||||||
|
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||||
|
{m.footer_popular()}
|
||||||
|
</h3>
|
||||||
|
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
|
||||||
|
{#each popularCities as city (city.slug)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={href('/weather/week/[location]', { location: city.slug })}
|
||||||
|
>{m.city_weather({ city: city.label })}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom bar: legal -->
|
||||||
|
<div
|
||||||
|
class="mt-10 flex flex-col items-start justify-between gap-3 border-t border-border/70 pt-5 text-xs text-muted-foreground sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<span>© {year} Drizz.li</span>
|
||||||
|
<nav aria-label={m.legal_nav()} class="flex flex-wrap items-center gap-x-5 gap-y-1">
|
||||||
|
<a class="underline-offset-2 hover:text-foreground hover:underline" href={href('/about')}
|
||||||
|
>{m.legal_about()}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={href('/legal/imprint')}>{m.legal_imprint()}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={href('/legal/privacy')}>{m.legal_privacy()}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={href('/legal/terms')}>{m.legal_terms()}</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -2,17 +2,23 @@
|
|||||||
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 PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
|
import { href, routePath } from '$lib/i18n';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
import SupporterBadge from '$lib/supporter/SupporterBadge.svelte';
|
||||||
|
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
|
||||||
|
|
||||||
|
import SettingsMenu from './settings-menu.svelte';
|
||||||
|
import ThemeIcon from './theme-icon.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onMenuToggle?: () => void;
|
onMenuToggle?: () => void;
|
||||||
@@ -26,11 +32,36 @@
|
|||||||
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Built as a string rather than inline markup: the pieces are optional, and
|
||||||
|
// separators spelled out in the template lose their spacing to Svelte's
|
||||||
|
// whitespace trimming ("Canton of Schwyz,Switzerland").
|
||||||
|
let locationRegion = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
|
||||||
|
// elevation rides along in the pill's muted part ("· Canton of Schwyz,
|
||||||
|
// Switzerland · 465m"); a 0 m coastal town is a real reading, only a
|
||||||
|
// missing value is dropped
|
||||||
|
let locationDetail = $derived(
|
||||||
|
[locationRegion, location?.elevation != null ? `${Math.round(location.elevation)}m` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
);
|
||||||
|
// the pill ellipses, so the full name still has to be readable somewhere
|
||||||
|
let locationLine = $derived([location?.name, locationDetail].filter(Boolean).join(' · '));
|
||||||
|
|
||||||
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
||||||
const themeTitles: Record<Theme, string> = {
|
const themeTitles: Record<Theme, () => string> = {
|
||||||
system: 'Theme: follow system',
|
system: m.theme_follow_system,
|
||||||
light: 'Theme: light',
|
light: m.theme_light_title,
|
||||||
dark: 'Theme: dark'
|
dark: m.theme_dark_title
|
||||||
};
|
};
|
||||||
|
|
||||||
function cycleTheme() {
|
function cycleTheme() {
|
||||||
@@ -42,16 +73,18 @@
|
|||||||
function navigateToLocation(newLocation: GeoLocation) {
|
function navigateToLocation(newLocation: GeoLocation) {
|
||||||
storedLocation.set(newLocation);
|
storedLocation.set(newLocation);
|
||||||
const locationRoute = buildLocationRoute(newLocation);
|
const locationRoute = buildLocationRoute(newLocation);
|
||||||
const currentPath = get(page).url.pathname;
|
const currentPath = routePath(get(page).url.pathname);
|
||||||
|
|
||||||
if (currentPath.startsWith('/weather/compare')) {
|
if (currentPath.startsWith('/weather/compare')) {
|
||||||
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
goto(href('/weather/compare/[location]', { location: locationRoute }));
|
||||||
} else if (currentPath.startsWith('/weather/14-day')) {
|
} else if (currentPath.startsWith('/weather/14-day')) {
|
||||||
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
goto(href('/weather/14-day/[location]', { location: locationRoute }));
|
||||||
} else if (currentPath.startsWith('/weather/historical')) {
|
} else if (currentPath.startsWith('/weather/historical')) {
|
||||||
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
goto(href('/weather/historical/[location]', { location: locationRoute }));
|
||||||
|
} else if (currentPath.startsWith('/weather/seasonal')) {
|
||||||
|
goto(href('/weather/seasonal/[location]', { location: locationRoute }));
|
||||||
} else {
|
} else {
|
||||||
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
goto(href('/weather/week/[location]', { location: locationRoute }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -59,110 +92,94 @@
|
|||||||
<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 -->
|
<!-- Mobile menu toggle. On phones this side and the settings side both take
|
||||||
<button
|
an equal share of the leftover width, which lands the search box dead
|
||||||
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"
|
centre; on md+ they collapse and the spacer below does the work. -->
|
||||||
onclick={onMenuToggle}
|
<div class="flex flex-1 items-center md:flex-none">
|
||||||
aria-label="Toggle menu"
|
<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"
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
onclick={onMenuToggle}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
aria-label={m.nav_toggle_menu()}
|
||||||
</svg>
|
>
|
||||||
</button>
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Current location display -->
|
<!-- Current location display -->
|
||||||
{#if location}
|
{#if location}
|
||||||
|
<!-- min-w-0 + a ceiling so a long "Sant Pere de Ribes, Catalonia, Spain"
|
||||||
|
ellipses inside the pill instead of pushing the search box off centre -->
|
||||||
<div
|
<div
|
||||||
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
|
class="hidden min-w-0 max-w-70 items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex xl:max-w-96"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
bind:this={flagEl}
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
||||||
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
|
<span class="min-w-0 truncate text-sm font-semibold text-foreground" title={locationLine}>
|
||||||
{location.name}
|
{location.name}
|
||||||
{#if location.admin1 || location.country}
|
{#if locationDetail}
|
||||||
<span class="font-normal text-muted-foreground">
|
<span class="font-normal text-muted-foreground">· {locationDetail}</span>
|
||||||
· {#if location.admin1}{location.admin1},
|
|
||||||
{/if}{location.country ?? ''}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Spacer -->
|
<!-- Spacer (md+ only: on phones the equal side columns centre the search) -->
|
||||||
<div class="flex-1"></div>
|
<div class="hidden flex-1 md:block"></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="Search location..."
|
label={m.search_placeholder()}
|
||||||
on:location={(event) => {
|
on:location={(event) => {
|
||||||
navigateToLocation(event.detail);
|
navigateToLocation(event.detail);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Measurement units -->
|
<!-- Phones only have room for one control, so units, theme and supporter
|
||||||
<UnitSelector />
|
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>
|
||||||
|
|
||||||
<!-- Premium status / unlock -->
|
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
|
||||||
<PremiumBadge />
|
so SupporterBadge still verifies the key on every viewport. -->
|
||||||
|
<div class="hidden items-center gap-3 md:flex">
|
||||||
<!-- Theme toggle: system → light → dark -->
|
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
|
||||||
<button
|
{#if SUPPORTER_ENABLED}
|
||||||
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"
|
<SupporterBadge />
|
||||||
onclick={cycleTheme}
|
|
||||||
title={themeTitles[$storedTheme]}
|
|
||||||
aria-label={themeTitles[$storedTheme]}
|
|
||||||
>
|
|
||||||
{#if $storedTheme === 'light'}
|
|
||||||
<!-- sun -->
|
|
||||||
<svg
|
|
||||||
class="h-4.5 w-4.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.75"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="4" />
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if $storedTheme === 'dark'}
|
|
||||||
<!-- moon -->
|
|
||||||
<svg
|
|
||||||
class="h-4.5 w-4.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.75"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<!-- monitor (system) -->
|
|
||||||
<svg
|
|
||||||
class="h-4.5 w-4.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.75"
|
|
||||||
>
|
|
||||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
|
||||||
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
|
||||||
|
<!-- Language: the locale lives in the URL, so this is a set of links -->
|
||||||
|
<LanguageSelector />
|
||||||
|
|
||||||
|
<!-- Measurement units -->
|
||||||
|
<UnitSelector />
|
||||||
|
|
||||||
|
<!-- Theme toggle: system → light → dark -->
|
||||||
|
<button
|
||||||
|
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onclick={cycleTheme}
|
||||||
|
title={themeTitles[$storedTheme]()}
|
||||||
|
aria-label={themeTitles[$storedTheme]()}
|
||||||
|
>
|
||||||
|
<ThemeIcon theme={$storedTheme} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// umbrella-with-rain mark (matches the favicon); shared by the sidebar home
|
||||||
|
// link and the footer so the brand reads the same in both places
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className = 'h-5 w-5' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||||
|
<!-- rain falls from above onto the canopy -->
|
||||||
|
<path stroke-linecap="round" d="M4.5 3v.01M19.5 3v.01M8.5 1.5v.01M15.5 2.5v.01" />
|
||||||
|
<path stroke-linecap="round" d="M12 4.5V6" />
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="none"
|
||||||
|
d="M4 14a8 8 0 0 1 16 0c-.66-1-1.99-1-2.66 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0C8.66 13 7.33 13 6.66 14 6 13 4.66 13 4 14Z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" d="M12 14v5a1.9 1.9 0 0 1-3.8 0" />
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type Theme, storedTheme } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import LanguageOptions from '$lib/components/language-options.svelte';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||||
|
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
import SupporterIcon from '$lib/supporter/SupporterIcon.svelte';
|
||||||
|
import UnlockDialog from '$lib/supporter/UnlockDialog.svelte';
|
||||||
|
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
|
||||||
|
import { isSupporter } from '$lib/supporter/store';
|
||||||
|
|
||||||
|
import ThemeIcon from './theme-icon.svelte';
|
||||||
|
|
||||||
|
// The topbar has room for one control on a phone, so units, theme and the
|
||||||
|
// supporter status share this menu instead of each carrying its own trigger.
|
||||||
|
const THEMES: { value: Theme; label: () => string }[] = [
|
||||||
|
{ value: 'system', label: m.theme_system },
|
||||||
|
{ value: 'light', label: m.theme_light },
|
||||||
|
{ value: 'dark', label: m.theme_dark }
|
||||||
|
];
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let unlockOpen = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger
|
||||||
|
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||||
|
aria-label={m.settings_title()}
|
||||||
|
title={m.settings_title()}
|
||||||
|
>
|
||||||
|
<!-- gear -->
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2v.2a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.2-2.9H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Content align="end" class="w-72 border-border">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<UnitOptions />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
{m.theme_label()}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||||
|
{#each THEMES as option (option.value)}
|
||||||
|
{@const active = $storedTheme === option.value}
|
||||||
|
<button
|
||||||
|
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
aria-pressed={active}
|
||||||
|
onclick={() => storedTheme.set(option.value)}
|
||||||
|
>
|
||||||
|
<ThemeIcon theme={option.value} class="h-4 w-4" />
|
||||||
|
{option.label()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LanguageOptions onSelect={() => (open = false)} />
|
||||||
|
|
||||||
|
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
|
||||||
|
{#if SUPPORTER_ENABLED}
|
||||||
|
<div class="border-t border-border/70 pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
|
||||||
|
onclick={() => {
|
||||||
|
open = false;
|
||||||
|
unlockOpen = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SupporterIcon
|
||||||
|
filled={$isSupporter}
|
||||||
|
class="h-4.5 w-4.5 shrink-0 {$isSupporter
|
||||||
|
? 'text-amber-500'
|
||||||
|
: 'text-muted-foreground'}"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block text-[13px] font-semibold">
|
||||||
|
{$isSupporter ? m.supporter_active() : m.supporter_support()}
|
||||||
|
</span>
|
||||||
|
<span class="block text-[11px] text-muted-foreground">
|
||||||
|
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
|
<UnlockDialog bind:open={unlockOpen} />
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Theme } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
theme: Theme;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { theme, class: className = 'h-4.5 w-4.5' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if theme === 'light'}
|
||||||
|
<!-- sun -->
|
||||||
|
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if theme === 'dark'}
|
||||||
|
<!-- moon -->
|
||||||
|
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- monitor (system) -->
|
||||||
|
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||||
|
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||||
|
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
<script lang="ts">
|
<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;
|
||||||
@@ -12,32 +20,43 @@
|
|||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
title: '7-Day Forecast',
|
title: m.nav_week,
|
||||||
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: 'Model Comparison',
|
title: m.nav_compare,
|
||||||
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: '14-Day Forecast',
|
title: m.nav_14day,
|
||||||
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: 'Historical',
|
title: m.nav_seasonal,
|
||||||
|
url: '/weather/seasonal' as const,
|
||||||
|
route: '/weather/seasonal/[location]' as const,
|
||||||
|
// rising trend line (long-range outlook)
|
||||||
|
iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: m.nav_historical,
|
||||||
url: '/weather/historical' as const,
|
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: 'Maps',
|
title: m.nav_maps,
|
||||||
url: '/weather/maps' as const,
|
url: '/weather/maps' as const,
|
||||||
// Heroicons "map" outline icon
|
// Heroicons "map" outline icon
|
||||||
iconPaths: [
|
iconPaths: [
|
||||||
@@ -46,7 +65,13 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
let currentPath = $derived($page.url.pathname);
|
// the URL carries a locale prefix; compare the neutral path behind it
|
||||||
|
let currentPath = $derived(routePath($page.url.pathname));
|
||||||
|
|
||||||
|
// Link straight to the location page instead of the bare redirect route: it
|
||||||
|
// saves a navigation, and the page cross-fade can then wait for the real
|
||||||
|
// page's data instead of flashing through an empty redirect stub.
|
||||||
|
let locationRoute = $derived(buildLocationRoute($storedLocation));
|
||||||
|
|
||||||
const isActive = (url: string) => {
|
const isActive = (url: string) => {
|
||||||
return currentPath === url || currentPath.startsWith(url + '/');
|
return currentPath === url || currentPath.startsWith(url + '/');
|
||||||
@@ -62,34 +87,17 @@
|
|||||||
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={resolve('/weather/week')}
|
href={href('/weather/week/[location]', { location: locationRoute })}
|
||||||
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
|
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="Drizzli home"
|
aria-label={m.nav_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"
|
||||||
>
|
>
|
||||||
<!-- umbrella-with-rain logo mark (matches favicon) -->
|
<LogoMark />
|
||||||
<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">
|
||||||
@@ -101,14 +109,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.title)}
|
{#each links as link (link.url)}
|
||||||
{@const active = isActive(link.url)}
|
{@const active = isActive(link.url)}
|
||||||
<a
|
<a
|
||||||
href={resolve(link.url)}
|
href={link.route ? href(link.route, { location: locationRoute }) : href(link.url)}
|
||||||
class="relative flex items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100 {active
|
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">
|
||||||
@@ -125,19 +133,49 @@
|
|||||||
</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 ? 'Expand sidebar' : 'Collapse sidebar'}
|
title={collapsed ? m.nav_expand_sidebar() : m.nav_collapse_sidebar()}
|
||||||
>
|
>
|
||||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
@@ -156,7 +194,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
|
<span class="ml-2.5 whitespace-nowrap">{m.nav_collapse()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
/** Optional muted line under the title (e.g. "Last updated ..."). */
|
||||||
|
subtitle?: string;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, subtitle, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{title} · Drizz.li</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<article class="mx-auto max-w-3xl py-2 lg:py-4">
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight lg:text-3xl">{title}</h1>
|
||||||
|
{#if subtitle}
|
||||||
|
<p class="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="prose-body mt-6">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Lightweight typography for long-form pages (about / legal), themed with the
|
||||||
|
app tokens so it reads correctly in light and dark. */
|
||||||
|
.prose-body {
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.prose-body :global(h2) {
|
||||||
|
margin: 2rem 0 0.6rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.prose-body :global(h3) {
|
||||||
|
margin: 1.4rem 0 0.4rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.prose-body :global(p) {
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
}
|
||||||
|
.prose-body :global(ul) {
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
}
|
||||||
|
.prose-body :global(li) {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.prose-body :global(a) {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.prose-body :global(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.prose-body :global(address) {
|
||||||
|
font-style: normal;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
.prose-body :global(hr) {
|
||||||
|
margin: 2rem 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
// each group maps a stored unit key to its selectable options
|
||||||
|
const UNIT_GROUPS: {
|
||||||
|
key: keyof UnitPrefs;
|
||||||
|
label: () => string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: 'temperature_unit',
|
||||||
|
label: m.unit_temperature,
|
||||||
|
options: [
|
||||||
|
{ value: 'celsius', label: '°C' },
|
||||||
|
{ value: 'fahrenheit', label: '°F' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wind_speed_unit',
|
||||||
|
label: m.unit_wind_speed,
|
||||||
|
options: [
|
||||||
|
{ value: 'kmh', label: 'km/h' },
|
||||||
|
{ value: 'ms', label: 'm/s' },
|
||||||
|
{ value: 'mph', label: 'mph' },
|
||||||
|
{ value: 'kn', label: 'kn' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'precipitation_unit',
|
||||||
|
label: m.unit_precipitation,
|
||||||
|
options: [
|
||||||
|
{ value: 'mm', label: 'mm' },
|
||||||
|
{ value: 'inch', label: 'inch' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function setUnit(key: keyof UnitPrefs, value: string) {
|
||||||
|
storedUnits.update((u) => ({ ...u, [key]: value }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#each UNIT_GROUPS as group (group.key)}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
{group.label()}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||||
|
{#each group.options as opt (opt.value)}
|
||||||
|
{@const active = $storedUnits[group.key] === opt.value}
|
||||||
|
<button
|
||||||
|
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
aria-pressed={active}
|
||||||
|
onclick={() => setUnit(group.key, opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -1,60 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||||
|
|
||||||
// each group maps a stored unit key to its selectable options
|
import * as m from '$lib/paraglide/messages';
|
||||||
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="Choose measurement units"
|
aria-label={m.units_aria()}
|
||||||
title="Units"
|
title={m.units_title()}
|
||||||
>
|
>
|
||||||
<!-- gauge icon -->
|
<!-- gauge icon -->
|
||||||
<svg
|
<svg
|
||||||
@@ -73,30 +28,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content align="end" class="w-64 border-border">
|
<Popover.Content align="end" class="w-64 border-border">
|
||||||
<div class="flex flex-col gap-4">
|
<UnitOptions />
|
||||||
{#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>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Locale-aware routing helpers.
|
||||||
|
*
|
||||||
|
* SvelteKit's `resolve()` returns the language-neutral path a route lives at;
|
||||||
|
* every link has to go through `localizeHref()` on top of that, or clicking it
|
||||||
|
* would drop the visitor back into the base locale (the reroute hook in
|
||||||
|
* `src/hooks.ts` strips the prefix again on the way in).
|
||||||
|
*/
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
import { type Locale, deLocalizeHref, locales, localizeHref } from '$lib/paraglide/runtime';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PathnameWithSearchOrHash,
|
||||||
|
RouteId,
|
||||||
|
RouteIdWithSearchOrHash,
|
||||||
|
RouteParams
|
||||||
|
} from '$app/types';
|
||||||
|
|
||||||
|
// Mirrors SvelteKit's own (non-exported) argument type for `resolve`, so a
|
||||||
|
// route id still demands exactly the params that route declares.
|
||||||
|
type StripSearchOrHash<T extends string> = T extends `${infer P}?${string}`
|
||||||
|
? P
|
||||||
|
: T extends `${infer P}#${string}`
|
||||||
|
? P
|
||||||
|
: T;
|
||||||
|
|
||||||
|
type ResolveArgs<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash> = T extends RouteId
|
||||||
|
? RouteParams<T> extends Record<string, never>
|
||||||
|
? [route: T]
|
||||||
|
: [route: T, params: RouteParams<T>]
|
||||||
|
: StripSearchOrHash<T> extends infer U extends RouteId
|
||||||
|
? RouteParams<U> extends Record<string, never>
|
||||||
|
? [route: T]
|
||||||
|
: [route: T, params: RouteParams<U>]
|
||||||
|
: [route: T];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `resolve()` for the current locale. Mirrors SvelteKit's own signature, so
|
||||||
|
* route ids and their params keep being type-checked.
|
||||||
|
*/
|
||||||
|
export function href<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
|
||||||
|
...args: ResolveArgs<T>
|
||||||
|
): string {
|
||||||
|
return localizeHref(resolve(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as `href`, but forced into a specific locale. */
|
||||||
|
export function hrefIn<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
|
||||||
|
locale: Locale,
|
||||||
|
...args: ResolveArgs<T>
|
||||||
|
): string {
|
||||||
|
return localizeHref(resolve(...args), { locale });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `goto()` that keeps the visitor in their language. */
|
||||||
|
export function gotoLocalized<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
|
||||||
|
...args: ResolveArgs<T>
|
||||||
|
) {
|
||||||
|
return goto(href(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The language-neutral path for a URL that still carries its locale prefix -
|
||||||
|
* what "which page am I on?" checks have to compare against.
|
||||||
|
*/
|
||||||
|
export function routePath(pathname: string): string {
|
||||||
|
return deLocalizeHref(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display names for the language switcher, in the language itself. */
|
||||||
|
export const LOCALE_LABELS: Record<Locale, string> = {
|
||||||
|
en: 'English',
|
||||||
|
de: 'Deutsch',
|
||||||
|
es: 'Español',
|
||||||
|
fr: 'Français',
|
||||||
|
it: 'Italiano'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOCALE_LIST = locales;
|
||||||
@@ -1,723 +0,0 @@
|
|||||||
<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 & 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>
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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 });
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
};
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<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 />
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* Paywall configuration.
|
|
||||||
*
|
|
||||||
* The frontend stays fully static and open source; the only server piece is the
|
|
||||||
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
|
|
||||||
* build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a
|
|
||||||
* `.env` file), otherwise the sensible drizz.li defaults are used.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const env = import.meta.env as Record<string, string | undefined>;
|
|
||||||
|
|
||||||
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
|
|
||||||
|
|
||||||
/** Base URL of the self-hosted verify API (drizzli-paywall). */
|
|
||||||
export const PAYWALL_API_BASE = stripTrailingSlash(
|
|
||||||
env.VITE_PAYWALL_API_BASE ?? 'https://paywall.drizz.li'
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
|
|
||||||
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
|
|
||||||
|
|
||||||
// ─── Location-based pricing ──────────────────────────────────────────────────
|
|
||||||
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
|
|
||||||
// from their location. Mirror that here so the paywall copy matches. Detection
|
|
||||||
// is timezone/locale based (no network geo lookup) and guarded for SSR.
|
|
||||||
|
|
||||||
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
|
|
||||||
const US_ZONES =
|
|
||||||
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
|
|
||||||
|
|
||||||
export type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
|
|
||||||
|
|
||||||
export function detectCurrency(): PremiumCurrency {
|
|
||||||
if (typeof Intl === 'undefined') return 'EUR';
|
|
||||||
let tz = '';
|
|
||||||
try {
|
|
||||||
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
if (/Zurich|Vaduz/.test(tz)) return 'CHF';
|
|
||||||
let region = '';
|
|
||||||
try {
|
|
||||||
if (typeof navigator !== 'undefined') {
|
|
||||||
region = new Intl.Locale(navigator.language).maximize().region || '';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
if (region === 'CH') return 'CHF';
|
|
||||||
if (region === 'US' || US_ZONES.test(tz)) return 'USD';
|
|
||||||
return 'EUR';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
|
|
||||||
export function getPremiumPrice(): string {
|
|
||||||
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
|
|
||||||
const currency = detectCurrency();
|
|
||||||
const symbol = CURRENCY_SYMBOL[currency];
|
|
||||||
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Short, human list of what premium unlocks (shown on the locked panel). */
|
|
||||||
export const PREMIUM_PERKS = [
|
|
||||||
'Historical weather & climate-normal comparisons',
|
|
||||||
'New premium features as they land'
|
|
||||||
];
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
/**
|
|
||||||
* Premium (subscription) state.
|
|
||||||
*
|
|
||||||
* The user pastes an access key once; it is stored locally and re-verified
|
|
||||||
* against the self-hosted verify API on load. The last good result is cached so
|
|
||||||
* gated content shows instantly on reload (and keeps working briefly offline)
|
|
||||||
* without waiting for the network round-trip.
|
|
||||||
*
|
|
||||||
* This gate is a convenience/honor-system gate: the frontend is open source and
|
|
||||||
* static, so it can be bypassed. Keeping the subscriber list server-side (in the
|
|
||||||
* paywall repo) is what makes it meaningful in practice.
|
|
||||||
*/
|
|
||||||
import { derived, get, writable } from 'svelte/store';
|
|
||||||
|
|
||||||
import { persisted } from 'svelte-persisted-store';
|
|
||||||
|
|
||||||
import { PAYWALL_API_BASE } from './config';
|
|
||||||
|
|
||||||
/** The subscriber's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
|
|
||||||
export const storedLicenseKey = persisted<string>('license_key', '');
|
|
||||||
|
|
||||||
export interface PremiumCache {
|
|
||||||
valid: boolean;
|
|
||||||
tier?: string;
|
|
||||||
/** ISO date the subscription lapses, or null for a lifetime key. */
|
|
||||||
expires?: string | null;
|
|
||||||
/** epoch ms of the last verify call */
|
|
||||||
checkedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
|
|
||||||
export const storedPremiumCache = persisted<PremiumCache | null>('premium_cache_v1', null);
|
|
||||||
|
|
||||||
export type PremiumStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
|
||||||
|
|
||||||
export interface PremiumState {
|
|
||||||
status: PremiumStatus;
|
|
||||||
tier?: string;
|
|
||||||
expires?: string | null;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Live verification state for the current session. */
|
|
||||||
export const premiumState = writable<PremiumState>({ status: 'idle' });
|
|
||||||
|
|
||||||
function notExpired(expires: string | null | undefined): boolean {
|
|
||||||
if (!expires) return true; // lifetime key
|
|
||||||
const t = Date.parse(expires);
|
|
||||||
return Number.isFinite(t) && t > Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether premium content should be shown. A live "valid"/"invalid" result wins;
|
|
||||||
* otherwise we fall back to the cached result (so a reload or a brief network
|
|
||||||
* blip doesn't lock a paying user out).
|
|
||||||
*/
|
|
||||||
export const isPremium = derived(
|
|
||||||
[premiumState, storedPremiumCache],
|
|
||||||
([$state, $cache]): boolean => {
|
|
||||||
if ($state.status === 'valid') return true;
|
|
||||||
if ($state.status === 'invalid') return false;
|
|
||||||
return !!($cache && $cache.valid && notExpired($cache.expires));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface VerifyResult {
|
|
||||||
valid: boolean;
|
|
||||||
tier?: string;
|
|
||||||
expires?: string | null;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a key against the API. On success the key is persisted and the cache
|
|
||||||
* updated. On an invalid key the stored key is left untouched (so an expired
|
|
||||||
* subscription can still show a "renew" state) but the cache is marked invalid.
|
|
||||||
*/
|
|
||||||
export async function verifyKey(key: string): Promise<VerifyResult> {
|
|
||||||
const trimmed = key.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
premiumState.set({ status: 'invalid' });
|
|
||||||
return { valid: false, error: 'Enter your access key.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
premiumState.set({ status: 'checking' });
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
|
||||||
headers: { accept: 'application/json' }
|
|
||||||
});
|
|
||||||
const data = (await res.json()) as VerifyResult;
|
|
||||||
|
|
||||||
if (res.ok && data.valid) {
|
|
||||||
storedLicenseKey.set(trimmed);
|
|
||||||
storedPremiumCache.set({
|
|
||||||
valid: true,
|
|
||||||
tier: data.tier,
|
|
||||||
expires: data.expires ?? null,
|
|
||||||
checkedAt: Date.now()
|
|
||||||
});
|
|
||||||
premiumState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
|
|
||||||
return { valid: true, tier: data.tier, expires: data.expires ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
storedPremiumCache.set({ valid: false, checkedAt: Date.now() });
|
|
||||||
premiumState.set({ status: 'invalid' });
|
|
||||||
return { valid: false };
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
premiumState.set({ status: 'error', error: message });
|
|
||||||
return { valid: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
|
|
||||||
export async function refreshPremium(): Promise<void> {
|
|
||||||
const key = get(storedLicenseKey);
|
|
||||||
if (!key) {
|
|
||||||
premiumState.set({ status: 'idle' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await verifyKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forget the key and premium state ("sign out"). */
|
|
||||||
export function clearLicense(): void {
|
|
||||||
storedLicenseKey.set('');
|
|
||||||
storedPremiumCache.set(null);
|
|
||||||
premiumState.set({ status: 'idle' });
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Picks a handful of cities around a location, for cross-referencing a forecast
|
||||||
|
* against places you already have a feel for.
|
||||||
|
*
|
||||||
|
* Open-Meteo's geocoding API can only search by name - there is no radius or
|
||||||
|
* reverse lookup - so the candidates come from a static GeoNames extract, cut
|
||||||
|
* into 10x10 degree tiles (see scripts/build-cities.mjs). Looking somewhere up
|
||||||
|
* costs one small tile fetch, which the browser then caches.
|
||||||
|
*/
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
|
||||||
|
/** [geonames id, name, country code, latitude, longitude, population/1000] */
|
||||||
|
type CityRow = [number, string, string, number, number, number];
|
||||||
|
|
||||||
|
export interface NearbyCity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
countryCode: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
population: number;
|
||||||
|
distanceKm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EARTH_RADIUS_KM = 6371;
|
||||||
|
const TILE_DEGREES = 10;
|
||||||
|
|
||||||
|
export function distanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = Math.PI / 180;
|
||||||
|
const dLat = (lat2 - lat1) * toRad;
|
||||||
|
const dLon = (lon2 - lon1) * toRad;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileKey(latitude: number, longitude: number): string {
|
||||||
|
const lat = Math.min(89.999, Math.max(-90, latitude));
|
||||||
|
const lon = ((((longitude + 180) % 360) + 360) % 360) - 180;
|
||||||
|
return `${Math.floor((lat + 90) / TILE_DEGREES)}_${Math.floor((lon + 180) / TILE_DEGREES)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileCache = new Map<string, Promise<CityRow[]>>();
|
||||||
|
let indexPromise: Promise<Set<string>> | null = null;
|
||||||
|
|
||||||
|
async function fetchJson<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${base}/data/cities/${path}`);
|
||||||
|
if (!res.ok) throw new Error(`${path}: ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiles exist only where there are cities, so the index turns an empty ocean
|
||||||
|
* tile into a no-op instead of a 404. */
|
||||||
|
function loadIndex(): Promise<Set<string>> {
|
||||||
|
indexPromise ??= fetchJson<string[]>('index.json')
|
||||||
|
.then((keys) => new Set(keys))
|
||||||
|
.catch((err) => {
|
||||||
|
indexPromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
return indexPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTile(key: string): Promise<CityRow[]> {
|
||||||
|
if (!(await loadIndex()).has(key)) return [];
|
||||||
|
|
||||||
|
let tile = tileCache.get(key);
|
||||||
|
if (!tile) {
|
||||||
|
tile = fetchJson<CityRow[]>(`${key}.json`).catch((err) => {
|
||||||
|
// a failed fetch must not poison the cache: the next visit retries
|
||||||
|
tileCache.delete(key);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
tileCache.set(key, tile);
|
||||||
|
}
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widening rings. Within 400 km the tile holds towns down to 20k; past that
|
||||||
|
* only cities above 300k, which is exactly the bias a wide search wants - if
|
||||||
|
* nothing is nearby, the answer should be a place people have heard of.
|
||||||
|
*/
|
||||||
|
const SEARCH_RADII_KM = [200, 400, 1500];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far out still counts as "the place you are already looking at". A village
|
||||||
|
* ends where its fields start; London's own boroughs sit 20 km from its centre
|
||||||
|
* and share its weather, so the radius grows with the size of the location.
|
||||||
|
*/
|
||||||
|
function samePlaceKm(population: number): number {
|
||||||
|
return 10 + 15 * Math.min(1, population / 5_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns up to `count` cities around the given point, ordered by distance.
|
||||||
|
*
|
||||||
|
* Candidates are ranked by population damped by distance, so a town up the
|
||||||
|
* valley can outrank a metropolis three hours away, and picks have to keep
|
||||||
|
* their distance from each other - otherwise a place like New York fills the
|
||||||
|
* list with its own boroughs, and a big city fills it with commuter suburbs
|
||||||
|
* that share its weather anyway.
|
||||||
|
*/
|
||||||
|
export async function findNearbyCities(
|
||||||
|
latitude: number,
|
||||||
|
longitude: number,
|
||||||
|
count = 10,
|
||||||
|
population = 0
|
||||||
|
): Promise<NearbyCity[]> {
|
||||||
|
const rows = await loadTile(tileKey(latitude, longitude));
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const ownFootprintKm = samePlaceKm(population);
|
||||||
|
|
||||||
|
let best: NearbyCity[] = [];
|
||||||
|
|
||||||
|
for (const radius of SEARCH_RADII_KM) {
|
||||||
|
const halfWeightKm = radius / 2;
|
||||||
|
// far-apart picks in a wide search, tight ones when everything is close
|
||||||
|
const minSeparationKm = Math.max(25, radius / 20);
|
||||||
|
|
||||||
|
const scored = rows
|
||||||
|
.map(([id, name, countryCode, lat, lon, popK]) => {
|
||||||
|
const dist = distanceKm(latitude, longitude, lat, lon);
|
||||||
|
return {
|
||||||
|
city: {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
countryCode,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
population: popK * 1000,
|
||||||
|
distanceKm: dist
|
||||||
|
},
|
||||||
|
score: popK / (1 + (dist / halfWeightKm) ** 2)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(({ city }) => city.distanceKm >= ownFootprintKm && city.distanceKm <= radius)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
const picked: NearbyCity[] = [];
|
||||||
|
for (const { city } of scored) {
|
||||||
|
if (picked.length === count) break;
|
||||||
|
const tooClose = picked.some(
|
||||||
|
(p) => distanceKm(p.latitude, p.longitude, city.latitude, city.longitude) < minSeparationKm
|
||||||
|
);
|
||||||
|
if (!tooClose) picked.push(city);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (picked.length >= count) return picked.sort((a, b) => a.distanceKm - b.distanceKm);
|
||||||
|
if (picked.length > best.length) best = picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
return best.sort((a, b) => a.distanceKm - b.distanceKm);
|
||||||
|
}
|
||||||
+400
-136
@@ -10,10 +10,12 @@
|
|||||||
* - 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';
|
||||||
@@ -23,6 +25,7 @@ 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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -207,6 +210,17 @@ 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 {
|
||||||
@@ -228,7 +242,10 @@ export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelSeriesData {
|
export interface ModelSeriesData {
|
||||||
modelName: string;
|
/** Stable identifier requested by the UI. */
|
||||||
|
modelId: string;
|
||||||
|
/** Concrete model identifier reported by the API (useful for seamless/best-match requests). */
|
||||||
|
resolvedModelId: string;
|
||||||
variables: Record<string, number[]>;
|
variables: Record<string, number[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +290,65 @@ 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
|
||||||
@@ -301,7 +377,13 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -403,6 +485,17 @@ 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)!),
|
||||||
@@ -413,7 +506,13 @@ 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);
|
||||||
@@ -440,7 +539,8 @@ 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),
|
||||||
@@ -454,14 +554,28 @@ export async function fetchModelComparison(
|
|||||||
timezone: params.timezone
|
timezone: params.timezone
|
||||||
};
|
};
|
||||||
|
|
||||||
const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
|
const responses = await fetchWeatherApi(
|
||||||
|
FORECAST_URL,
|
||||||
|
forecastApiParams,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ signal: options.signal }
|
||||||
|
);
|
||||||
|
if (responses.length === 0) throw new Error('The weather service returned no model data.');
|
||||||
|
if (responses.length !== params.models.length) {
|
||||||
|
throw new Error(
|
||||||
|
`The weather service returned ${responses.length} model responses for ${params.models.length} requested models.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// With multiple models, we get one response per model
|
// 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
|
||||||
@@ -491,16 +605,22 @@ export async function fetchModelComparison(
|
|||||||
);
|
);
|
||||||
hourlyFlat['time'] = timeInUnixSeconds;
|
hourlyFlat['time'] = timeInUnixSeconds;
|
||||||
|
|
||||||
for (const response of responses) {
|
for (const [responseIndex, response] of responses.entries()) {
|
||||||
const modelHourly = response.hourly();
|
const modelHourly = response.hourly();
|
||||||
if (!modelHourly) continue;
|
if (!modelHourly) continue;
|
||||||
|
|
||||||
// Determine model name from the response
|
// The multi-model endpoint preserves request order and returns one response
|
||||||
|
// per requested model, including unavailable regional models. Keep that
|
||||||
|
// requested id as the stable UI key. The concrete id reported by Open-Meteo
|
||||||
|
// is metadata only: seamless and best-match requests may resolve to a
|
||||||
|
// different underlying domain.
|
||||||
const modelEnum = response.model();
|
const modelEnum = response.model();
|
||||||
const modelName = modelEnumToString(modelEnum);
|
const resolvedModelId = Model[modelEnum] ?? `model_${modelEnum}`;
|
||||||
|
const modelId = params.models[responseIndex] ?? resolvedModelId;
|
||||||
|
|
||||||
const modelData: ModelSeriesData = {
|
const modelData: ModelSeriesData = {
|
||||||
modelName,
|
modelId,
|
||||||
|
resolvedModelId,
|
||||||
variables: {}
|
variables: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -513,7 +633,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}_${modelName}`;
|
const flatKey = `${varName}_${modelId}`;
|
||||||
hourlyFlat[flatKey] = values;
|
hourlyFlat[flatKey] = values;
|
||||||
|
|
||||||
// Record unit
|
// Record unit
|
||||||
@@ -691,130 +811,6 @@ export async function fetchEnsembleForecast(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Model Enum Mapping ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps the SDK Model enum integer to a string model name.
|
|
||||||
* This table must stay in sync with the @openmeteo/sdk Model enum.
|
|
||||||
*/
|
|
||||||
function modelEnumToString(modelEnum: number): string {
|
|
||||||
const modelMap: Record<number, string> = {
|
|
||||||
0: 'undefined',
|
|
||||||
1: 'best_match',
|
|
||||||
2: 'gfs_seamless',
|
|
||||||
3: 'gfs_global',
|
|
||||||
4: 'gfs_hrrr',
|
|
||||||
5: 'meteofrance_seamless',
|
|
||||||
6: 'meteofrance_arpege_seamless',
|
|
||||||
7: 'meteofrance_arpege_world',
|
|
||||||
8: 'meteofrance_arpege_europe',
|
|
||||||
9: 'meteofrance_arome_seamless',
|
|
||||||
10: 'meteofrance_arome_france',
|
|
||||||
11: 'meteofrance_arome_france_hd',
|
|
||||||
12: 'jma_seamless',
|
|
||||||
13: 'jma_msm',
|
|
||||||
14: 'jms_gsm',
|
|
||||||
15: 'jma_gsm',
|
|
||||||
16: 'gem_seamless',
|
|
||||||
17: 'gem_global',
|
|
||||||
18: 'gem_regional',
|
|
||||||
19: 'gem_hrdps_continental',
|
|
||||||
20: 'icon_seamless',
|
|
||||||
21: 'icon_global',
|
|
||||||
22: 'icon_eu',
|
|
||||||
23: 'icon_d2',
|
|
||||||
24: 'ecmwf_ifs04',
|
|
||||||
25: 'metno_nordic',
|
|
||||||
26: 'era5_seamless',
|
|
||||||
27: 'era5',
|
|
||||||
28: 'cerra',
|
|
||||||
29: 'era5_land',
|
|
||||||
30: 'ecmwf_ifs',
|
|
||||||
31: 'gwam',
|
|
||||||
32: 'ewam',
|
|
||||||
33: 'glofas_seamless_v3',
|
|
||||||
34: 'glofas_forecast_v3',
|
|
||||||
35: 'glofas_consolidated_v3',
|
|
||||||
36: 'glofas_seamless_v4',
|
|
||||||
37: 'glofas_forecast_v4',
|
|
||||||
38: 'glofas_consolidated_v4',
|
|
||||||
39: 'gfs025',
|
|
||||||
40: 'gfs05',
|
|
||||||
41: 'CMCC_CM2_VHR4',
|
|
||||||
42: 'FGOALS_f3_H_highresSST',
|
|
||||||
43: 'FGOALS_f3_H',
|
|
||||||
44: 'HiRAM_SIT_HR',
|
|
||||||
45: 'MRI_AGCM3_2_S',
|
|
||||||
46: 'EC_Earth3P_HR',
|
|
||||||
47: 'MPI_ESM1_2_XR',
|
|
||||||
48: 'NICAM16_8S',
|
|
||||||
49: 'cams_europe',
|
|
||||||
50: 'cams_global',
|
|
||||||
51: 'cfsv2',
|
|
||||||
52: 'era5_ocean',
|
|
||||||
53: 'cma_grapes_global',
|
|
||||||
54: 'bom_access_global',
|
|
||||||
55: 'bom_access_global_ensemble',
|
|
||||||
56: 'arpae_cosmo_seamless',
|
|
||||||
57: 'arpae_cosmo_2i',
|
|
||||||
58: 'arpae_cosmo_2i_ruc',
|
|
||||||
59: 'arpae_cosmo_5m',
|
|
||||||
60: 'ecmwf_ifs025',
|
|
||||||
61: 'ecmwf_aifs025',
|
|
||||||
62: 'gfs013',
|
|
||||||
63: 'gfs_graphcast025',
|
|
||||||
64: 'ecmwf_wam025',
|
|
||||||
65: 'meteofrance_wave',
|
|
||||||
66: 'meteofrance_currents',
|
|
||||||
67: 'ecmwf_wam025_ensemble',
|
|
||||||
68: 'ncep_gfswave025',
|
|
||||||
69: 'ncep_gefswave025',
|
|
||||||
70: 'knmi_seamless',
|
|
||||||
71: 'knmi_harmonie_arome_europe',
|
|
||||||
72: 'knmi_harmonie_arome_netherlands',
|
|
||||||
73: 'dmi_seamless',
|
|
||||||
74: 'dmi_harmonie_arome_europe',
|
|
||||||
75: 'metno_seamless',
|
|
||||||
76: 'era5_ensemble',
|
|
||||||
77: 'ecmwf_ifs_analysis',
|
|
||||||
78: 'ecmwf_ifs_long_window',
|
|
||||||
79: 'ecmwf_ifs_analysis_long_window',
|
|
||||||
80: 'ukmo_global_deterministic_10km',
|
|
||||||
81: 'ukmo_uk_deterministic_2km',
|
|
||||||
82: 'ukmo_seamless',
|
|
||||||
83: 'ncep_gfswave016',
|
|
||||||
84: 'ncep_nbm_conus',
|
|
||||||
85: 'ukmo_global_ensemble_20km',
|
|
||||||
86: 'ecmwf_aifs025_single',
|
|
||||||
87: 'jma_jaxa_himawari',
|
|
||||||
88: 'eumetsat_sarah3',
|
|
||||||
89: 'eumetsat_lsa_saf_msg',
|
|
||||||
90: 'eumetsat_lsa_saf_iodc',
|
|
||||||
91: 'satellite_radiation_seamless',
|
|
||||||
92: 'kma_gdps',
|
|
||||||
93: 'kma_ldps',
|
|
||||||
94: 'kma_seamless',
|
|
||||||
95: 'italia_meteo_arpae_icon_2i',
|
|
||||||
96: 'ukmo_uk_ensemble_2km',
|
|
||||||
97: 'meteofrance_arome_france_hd_15min',
|
|
||||||
98: 'meteofrance_arome_france_15min',
|
|
||||||
99: 'meteoswiss_icon_ch1',
|
|
||||||
100: 'meteoswiss_icon_ch2',
|
|
||||||
101: 'meteoswiss_icon_ch1_ensemble',
|
|
||||||
102: 'meteoswiss_icon_ch2_ensemble',
|
|
||||||
103: 'meteoswiss_icon_seamless',
|
|
||||||
104: 'ncep_nam_conus',
|
|
||||||
105: 'icon_d2_ruc',
|
|
||||||
106: 'ecmwf_seas5',
|
|
||||||
107: 'ecmwf_ec46',
|
|
||||||
108: 'ecmwf_seasonal_seamless',
|
|
||||||
109: 'ecmwf_ifs_seamless',
|
|
||||||
110: 'jma_jaxa_mtg_fci',
|
|
||||||
111: 'gem_hrdps_west'
|
|
||||||
};
|
|
||||||
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Historical (Archive) Types ─────────────────────────────────────────────
|
// ─── Historical (Archive) Types ─────────────────────────────────────────────
|
||||||
|
|
||||||
export interface HistoricalDailyData {
|
export interface HistoricalDailyData {
|
||||||
@@ -842,6 +838,8 @@ 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 {
|
||||||
@@ -901,7 +899,8 @@ 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> = {};
|
||||||
@@ -1084,3 +1083,268 @@ 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current page has enough data on screen to be worth revealing.
|
||||||
|
*
|
||||||
|
* The weather pages fetch their forecast *after* the route swap, so a plain
|
||||||
|
* navigation transition would cross-fade one skeleton into another and then cut
|
||||||
|
* hard to the real content. The layout holds its transition open on this flag
|
||||||
|
* instead, so the fade lines up with the page actually being loaded.
|
||||||
|
*/
|
||||||
|
export const pageContentReady = writable(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised by the layout just before it captures a navigation away from the maps
|
||||||
|
* page. The map is a cross-origin iframe, which browsers do not paint into a
|
||||||
|
* view transition snapshot - captured bare, the outgoing page would carry a
|
||||||
|
* hole where the map was. The maps page answers by laying an opaque
|
||||||
|
* same-origin cover over the iframe (the same one that hides the map while it
|
||||||
|
* boots), so the snapshot shows a clean panel and the cross-fade has something
|
||||||
|
* real to fade from. The layout lowers it again once the navigation is
|
||||||
|
* through.
|
||||||
|
*/
|
||||||
|
export const mapTransitionCover = writable(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the layout before it swaps to a route that fetches its own data.
|
||||||
|
*
|
||||||
|
* The layout owns the "not ready yet" side deliberately: the incoming page's
|
||||||
|
* effects have not necessarily run at that point (rendering is paused inside a
|
||||||
|
* view transition), so a page that cleared the flag itself would sometimes be
|
||||||
|
* announced as ready before it had fetched anything.
|
||||||
|
*/
|
||||||
|
export function markPageLoading(): void {
|
||||||
|
pageContentReady.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The counterpart, for a route that has nothing to wait for (the maps page, the
|
||||||
|
* legal pages). Without it a navigation away from a page that never resolved
|
||||||
|
* would leave the flag stuck on "loading", and the layout's overlay would sit on
|
||||||
|
* top of a page that is perfectly finished.
|
||||||
|
*/
|
||||||
|
export function markPageReady(): void {
|
||||||
|
pageContentReady.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare a page's readiness. Pass a getter for "my data has arrived". Only
|
||||||
|
* ever sets the flag - clearing it is the layout's job (see above).
|
||||||
|
*/
|
||||||
|
export function reportPageReady(isReady: () => boolean): void {
|
||||||
|
$effect(() => {
|
||||||
|
if (isReady()) pageContentReady.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -86,6 +86,37 @@ 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);
|
||||||
|
|
||||||
@@ -107,23 +138,33 @@ 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);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
import SupporterIcon from './SupporterIcon.svelte';
|
||||||
|
import UnlockDialog from './UnlockDialog.svelte';
|
||||||
|
import { isSupporter, refreshSupporter } from './store';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
// Verify once on load so the badge reflects real status site-wide.
|
||||||
|
onMount(refreshSupporter);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors {$isSupporter
|
||||||
|
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
|
||||||
|
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||||
|
onclick={() => (open = true)}
|
||||||
|
title={$isSupporter ? m.supporter_active() : m.supporter_support()}
|
||||||
|
aria-label={$isSupporter ? m.supporter_active() : m.supporter_support()}
|
||||||
|
>
|
||||||
|
<SupporterIcon filled={$isSupporter} />
|
||||||
|
<span class="hidden sm:inline">{m.supporter_badge()}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<UnlockDialog bind:open />
|
||||||
@@ -1,36 +1,39 @@
|
|||||||
<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 { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
|
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
|
||||||
import { isPremium, premiumState, refreshPremium } from './premium';
|
import { isSupporter, refreshSupporter, supporterState } from './store';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Short feature name shown in the locked panel headline. */
|
/** Short feature name shown in the locked panel headline. */
|
||||||
feature?: string;
|
feature: string;
|
||||||
children: import('svelte').Snippet;
|
children: import('svelte').Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { feature = 'This page', children }: Props = $props();
|
let { feature, children }: Props = $props();
|
||||||
|
|
||||||
let unlockOpen = $state(false);
|
let unlockOpen = $state(false);
|
||||||
const price = getPremiumPrice();
|
const price = getSupporterPrice();
|
||||||
|
|
||||||
// Re-verify the stored key whenever the gate mounts.
|
// Re-verify the stored key whenever the gate mounts.
|
||||||
onMount(refreshPremium);
|
onMount(refreshSupporter);
|
||||||
|
|
||||||
// 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($premiumState.status === 'checking' && !$isPremium);
|
let initialChecking = $derived($supporterState.status === 'checking' && !$isSupporter);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isPremium}
|
{#if $isSupporter}
|
||||||
{@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>
|
||||||
Checking your subscription…
|
{m.supporter_checking()}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
@@ -39,26 +42,20 @@
|
|||||||
<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"
|
||||||
>
|
>
|
||||||
<!-- padlock -->
|
<SupporterIcon class="h-7 w-7" />
|
||||||
<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>
|
||||||
|
|
||||||
<h2 class="text-xl font-bold tracking-tight">{feature} is a premium feature</h2>
|
<!-- the feature name comes from a page subtitle ("météo historique"), which is
|
||||||
|
lowercase mid-sentence and has to be lifted at the start of this one -->
|
||||||
|
<h2 class="text-xl font-bold tracking-tight first-letter:uppercase">
|
||||||
|
{m.supporter_gate_title({ feature })}
|
||||||
|
</h2>
|
||||||
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
|
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
|
||||||
Support this open-source weather project and unlock the extras from {price}.
|
{m.supporter_gate_body({ 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 PREMIUM_PERKS as perk (perk)}
|
{#each SUPPORTER_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"
|
||||||
@@ -81,20 +78,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"
|
||||||
>
|
>
|
||||||
Subscribe
|
{m.supporter_become()}
|
||||||
</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)}
|
||||||
>
|
>
|
||||||
I have a key
|
{m.supporter_have_key()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $premiumState.status === 'invalid'}
|
{#if $supporterState.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">
|
||||||
Your saved key is no longer valid or has expired.
|
{m.supporter_key_expired()}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* The supporter mark. A heart rather than a padlock: nothing here is locked
|
||||||
|
* away as a punishment - the extras are a thank-you for chipping in, and a
|
||||||
|
* padlock framed it as a hard wall. Filled once someone is supporting,
|
||||||
|
* outlined as an invitation before that.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
filled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { filled = false, class: className = 'h-4 w-4' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if filled}
|
||||||
|
<svg class={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M12 21.1l-1.45-1.32C5.4 15.1 2 12 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.5-3.4 6.6-8.55 11.29L12 21.1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class={className}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.9"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 21.1l-1.45-1.32C5.4 15.1 2 12 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.5-3.4 6.6-8.55 11.29L12 21.1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
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 { SIGNUP_URL, getPremiumPrice } from './config';
|
import * as m from '$lib/paraglide/messages';
|
||||||
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
|
|
||||||
|
import { SIGNUP_URL, getSupporterPrice } from './config';
|
||||||
|
import { clearLicense, isSupporter, storedLicenseKey, supporterState, verifyKey } from './store';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
|
|
||||||
let { open = $bindable(false) }: Props = $props();
|
let { open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
const price = getPremiumPrice();
|
const price = getSupporterPrice();
|
||||||
|
|
||||||
// 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('');
|
||||||
@@ -40,7 +42,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 ?? 'That key is not valid or has expired.';
|
localError = result.error ?? m.supporter_key_invalid();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,23 +52,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let expiresLabel = $derived.by(() => {
|
let expiresLabel = $derived.by(() => {
|
||||||
const exp = $premiumState.expires;
|
const exp = $supporterState.expires;
|
||||||
if ($premiumState.status !== 'valid') return null;
|
if ($supporterState.status !== 'valid') return null;
|
||||||
if (!exp) return 'Lifetime access';
|
if (!exp) return m.supporter_lifetime();
|
||||||
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
|
return m.supporter_active_until({ date: 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>Drizz.li Premium</Dialog.Title>
|
<Dialog.Title>{m.supporter_dialog_title()}</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Paste the access key from your subscription email to unlock premium pages.
|
{m.supporter_dialog_desc()}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
{#if $isPremium && $premiumState.status === 'valid'}
|
{#if $isSupporter && $supporterState.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"
|
||||||
>
|
>
|
||||||
@@ -81,7 +83,7 @@
|
|||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">Premium is active</p>
|
<p class="font-semibold">{m.supporter_extras_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>
|
||||||
@@ -89,7 +91,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">Access key</Label>
|
<Label for="license-key">{m.supporter_access_key()}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="license-key"
|
id="license-key"
|
||||||
bind:value={keyInput}
|
bind:value={keyInput}
|
||||||
@@ -102,36 +104,36 @@
|
|||||||
|
|
||||||
{#if localError}
|
{#if localError}
|
||||||
<p class="text-sm text-destructive">{localError}</p>
|
<p class="text-sm text-destructive">{localError}</p>
|
||||||
{:else if $premiumState.status === 'error'}
|
{:else if $supporterState.status === 'error'}
|
||||||
<p class="text-sm text-destructive">
|
<p class="text-sm text-destructive">
|
||||||
Couldn't reach the server. Check your connection and try again.
|
{m.supporter_server_unreachable()}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Button type="submit" disabled={submitting || !keyInput.trim()}>
|
<Button type="submit" disabled={submitting || !keyInput.trim()}>
|
||||||
{#if submitting}Verifying…{:else}Unlock{/if}
|
{#if submitting}{m.supporter_verifying()}{:else}{m.supporter_unlock_button()}{/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 $isPremium}
|
{#if $isSupporter}
|
||||||
<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}
|
||||||
>
|
>
|
||||||
Remove key from this device
|
{m.supporter_remove_key()}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-center text-xs text-muted-foreground">
|
<p class="text-center text-xs text-muted-foreground">
|
||||||
No key yet?
|
{m.supporter_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"
|
||||||
>
|
>
|
||||||
Subscribe from {price}
|
{m.supporter_support_from({ price })}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supporter configuration.
|
||||||
|
*
|
||||||
|
* The frontend stays fully static and open source; the only server piece is the
|
||||||
|
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
|
||||||
|
* build at your deployment with the `VITE_SUPPORTER_*` env vars (e.g. in a
|
||||||
|
* `.env` file), otherwise the sensible drizz.li defaults are used. The
|
||||||
|
* pre-rename `VITE_PAYWALL_*` names still work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const env = import.meta.env as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master switch for the supporter features. While this is `false` everything
|
||||||
|
* supporter-related is parked: the nav entry points are hidden, no key is
|
||||||
|
* verified, and every gated page renders as if the visitor were a supporter.
|
||||||
|
* The logic stays in place — set `VITE_SUPPORTER_ENABLED=true` (or default this
|
||||||
|
* to `true`) to bring it back.
|
||||||
|
*/
|
||||||
|
export const SUPPORTER_ENABLED = env.VITE_SUPPORTER_ENABLED === 'true';
|
||||||
|
|
||||||
|
/** Base URL of the self-hosted verify API (drizzli-paywall). */
|
||||||
|
export const SUPPORTER_API_BASE = stripTrailingSlash(
|
||||||
|
env.VITE_SUPPORTER_API_BASE ?? env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Where prospective supporters go to sign up (the verify API's signup form). */
|
||||||
|
export const SIGNUP_URL =
|
||||||
|
env.VITE_SUPPORTER_SIGNUP_URL ?? env.VITE_PAYWALL_SIGNUP_URL ?? `${SUPPORTER_API_BASE}/`;
|
||||||
|
|
||||||
|
// ─── Location-based pricing ──────────────────────────────────────────────────
|
||||||
|
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
|
||||||
|
// from their location. Mirror that here so the supporter copy matches. Detection
|
||||||
|
// is timezone/locale based (no network geo lookup) and guarded for SSR.
|
||||||
|
|
||||||
|
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
|
||||||
|
const US_ZONES =
|
||||||
|
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
|
||||||
|
|
||||||
|
export type SupporterCurrency = 'EUR' | 'USD' | 'CHF';
|
||||||
|
|
||||||
|
export function detectCurrency(): SupporterCurrency {
|
||||||
|
if (typeof Intl === 'undefined') return 'EUR';
|
||||||
|
let tz = '';
|
||||||
|
try {
|
||||||
|
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (/Zurich|Vaduz/.test(tz)) return 'CHF';
|
||||||
|
let region = '';
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
region = new Intl.Locale(navigator.language).maximize().region || '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (region === 'CH') return 'CHF';
|
||||||
|
if (region === 'US' || US_ZONES.test(tz)) return 'USD';
|
||||||
|
return 'EUR';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display price for the locked panel, e.g. "€3 / month" or "CHF 3 / Monat". */
|
||||||
|
export function getSupporterPrice(): string {
|
||||||
|
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
|
||||||
|
// deployment's .env keeps working.
|
||||||
|
const configured = env.VITE_SUPPORTER_PRICE ?? env.VITE_PREMIUM_PRICE;
|
||||||
|
if (configured) return configured;
|
||||||
|
const currency = detectCurrency();
|
||||||
|
const symbol = CURRENCY_SYMBOL[currency];
|
||||||
|
const amount = currency === 'CHF' ? `${symbol} 3` : `${symbol}3`;
|
||||||
|
return m.supporter_price_per_month({ amount });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short, human list of what supporting unlocks (shown on the locked panel).
|
||||||
|
* A function, not a constant: the copy has to follow the active locale. */
|
||||||
|
export const SUPPORTER_PERKS = (): string[] => [
|
||||||
|
m.supporter_perk_historical(),
|
||||||
|
m.supporter_perk_seasonal(),
|
||||||
|
m.supporter_perk_future()
|
||||||
|
];
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Supporter (subscription) state.
|
||||||
|
*
|
||||||
|
* The user pastes an access key once; it is stored locally and re-verified
|
||||||
|
* against the self-hosted verify API on load. The last good result is cached so
|
||||||
|
* gated content shows instantly on reload (and keeps working briefly offline)
|
||||||
|
* without waiting for the network round-trip.
|
||||||
|
*
|
||||||
|
* This gate is a convenience/honor-system gate: the frontend is open source and
|
||||||
|
* static, so it can be bypassed. Keeping the subscriber list server-side (in the
|
||||||
|
* verify API's repo) is what makes it meaningful in practice.
|
||||||
|
*/
|
||||||
|
import { derived, get, writable } from 'svelte/store';
|
||||||
|
|
||||||
|
import { persisted } from 'svelte-persisted-store';
|
||||||
|
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
import { SUPPORTER_API_BASE, SUPPORTER_ENABLED } from './config';
|
||||||
|
|
||||||
|
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
|
||||||
|
export const storedLicenseKey = persisted<string>('license_key', '');
|
||||||
|
|
||||||
|
export interface SupporterCache {
|
||||||
|
valid: boolean;
|
||||||
|
tier?: string;
|
||||||
|
/** ISO date the subscription lapses, or null for a lifetime key. */
|
||||||
|
expires?: string | null;
|
||||||
|
/** epoch ms of the last verify call */
|
||||||
|
checkedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_KEY = 'supporter_cache_v1';
|
||||||
|
const LEGACY_CACHE_KEY = 'premium_cache_v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries the pre-rename cache over on first load, so supporters who already
|
||||||
|
* verified aren't shown a locked page while the key re-verifies.
|
||||||
|
*/
|
||||||
|
function readLegacyCache(): SupporterCache | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(CACHE_KEY)) return null;
|
||||||
|
const legacy = localStorage.getItem(LEGACY_CACHE_KEY);
|
||||||
|
if (!legacy) return null;
|
||||||
|
localStorage.removeItem(LEGACY_CACHE_KEY);
|
||||||
|
return JSON.parse(legacy) as SupporterCache;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
|
||||||
|
export const storedSupporterCache = persisted<SupporterCache | null>(CACHE_KEY, readLegacyCache());
|
||||||
|
|
||||||
|
export type SupporterStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
||||||
|
|
||||||
|
export interface SupporterState {
|
||||||
|
status: SupporterStatus;
|
||||||
|
tier?: string;
|
||||||
|
expires?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live verification state for the current session. */
|
||||||
|
export const supporterState = writable<SupporterState>({ status: 'idle' });
|
||||||
|
|
||||||
|
function notExpired(expires: string | null | undefined): boolean {
|
||||||
|
if (!expires) return true; // lifetime key
|
||||||
|
const t = Date.parse(expires);
|
||||||
|
return Number.isFinite(t) && t > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether supporter content should be shown. A live "valid"/"invalid" result
|
||||||
|
* wins; otherwise we fall back to the cached result (so a reload or a brief
|
||||||
|
* network blip doesn't lock a paying user out).
|
||||||
|
*
|
||||||
|
* While supporter features are parked (`SUPPORTER_ENABLED === false`) this is always
|
||||||
|
* true, so every gated page renders unlocked for everyone.
|
||||||
|
*/
|
||||||
|
export const isSupporter = derived(
|
||||||
|
[supporterState, storedSupporterCache],
|
||||||
|
([$state, $cache]): boolean => {
|
||||||
|
if (!SUPPORTER_ENABLED) return true;
|
||||||
|
if ($state.status === 'valid') return true;
|
||||||
|
if ($state.status === 'invalid') return false;
|
||||||
|
return !!($cache && $cache.valid && notExpired($cache.expires));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface VerifyResult {
|
||||||
|
valid: boolean;
|
||||||
|
tier?: string;
|
||||||
|
expires?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a key against the API. On success the key is persisted and the cache
|
||||||
|
* updated. On an invalid key the stored key is left untouched (so an expired
|
||||||
|
* subscription can still show a "renew" state) but the cache is marked invalid.
|
||||||
|
*/
|
||||||
|
export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||||
|
// Parked: never touch the verify API (and report success, since everything
|
||||||
|
// is unlocked anyway).
|
||||||
|
if (!SUPPORTER_ENABLED) return { valid: true, expires: null };
|
||||||
|
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
supporterState.set({ status: 'invalid' });
|
||||||
|
return { valid: false, error: m.supporter_enter_key() };
|
||||||
|
}
|
||||||
|
|
||||||
|
supporterState.set({ status: 'checking' });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SUPPORTER_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||||
|
headers: { accept: 'application/json' }
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as VerifyResult;
|
||||||
|
|
||||||
|
if (res.ok && data.valid) {
|
||||||
|
storedLicenseKey.set(trimmed);
|
||||||
|
storedSupporterCache.set({
|
||||||
|
valid: true,
|
||||||
|
tier: data.tier,
|
||||||
|
expires: data.expires ?? null,
|
||||||
|
checkedAt: Date.now()
|
||||||
|
});
|
||||||
|
supporterState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
|
||||||
|
return { valid: true, tier: data.tier, expires: data.expires ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
storedSupporterCache.set({ valid: false, checkedAt: Date.now() });
|
||||||
|
supporterState.set({ status: 'invalid' });
|
||||||
|
return { valid: false };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
supporterState.set({ status: 'error', error: message });
|
||||||
|
return { valid: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
|
||||||
|
export async function refreshSupporter(): Promise<void> {
|
||||||
|
if (!SUPPORTER_ENABLED) return;
|
||||||
|
|
||||||
|
const key = get(storedLicenseKey);
|
||||||
|
if (!key) {
|
||||||
|
supporterState.set({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await verifyKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the key and supporter state ("sign out"). */
|
||||||
|
export function clearLicense(): void {
|
||||||
|
storedLicenseKey.set('');
|
||||||
|
storedSupporterCache.set(null);
|
||||||
|
supporterState.set({ status: 'idle' });
|
||||||
|
}
|
||||||
+59
-13
@@ -1,12 +1,52 @@
|
|||||||
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 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises anything date-shaped into a plain `Date`, or null when it does not
|
||||||
|
* describe a real instant.
|
||||||
|
*
|
||||||
|
* Two reasons every helper below starts here:
|
||||||
|
*
|
||||||
|
* 1. date-fns copies its input with `new date.constructor(value)`. Hand it the
|
||||||
|
* reactive `SvelteDate` the pages use for the selected day and it builds
|
||||||
|
* *another* SvelteDate, then reads the copy's fields back through memoised
|
||||||
|
* signals - a signal graph per formatted timestamp, in a path that runs
|
||||||
|
* hundreds of times per render.
|
||||||
|
* 2. date-fns throws `RangeError: Invalid time value` on an invalid date. Thrown
|
||||||
|
* from inside a render (or inside a view-transition callback, where it
|
||||||
|
* surfaces as an unhandled rejection) that takes the whole page down for what
|
||||||
|
* is really one unformattable cell.
|
||||||
|
*/
|
||||||
|
function plainDate(date: Date | null | undefined): Date | null {
|
||||||
|
const time = date?.getTime?.();
|
||||||
|
if (time == null || !Number.isFinite(time)) return null;
|
||||||
|
// already a plain Date: no copy needed
|
||||||
|
return date!.constructor === Date ? (date as Date) : new Date(time);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
|
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
|
||||||
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
|
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
|
||||||
|
*
|
||||||
|
* Returns '' for a date or zone it cannot format - callers compare these strings
|
||||||
|
* or print them, and both degrade gracefully on an empty one.
|
||||||
*/
|
*/
|
||||||
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
|
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
|
||||||
return formatInTimeZone(date, timeZone, pattern);
|
const d = plainDate(date);
|
||||||
|
if (!d || !timeZone) return '';
|
||||||
|
return formatInTimeZone(d, timeZone, pattern, {
|
||||||
|
locale: DATE_LOCALES[getLocale()] ?? enGB
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,16 +54,20 @@ export function formatZoned(date: Date, timeZone: string, pattern: string): stri
|
|||||||
* Important for comparing weather forecast days against a selected date.
|
* Important for comparing weather forecast days against a selected date.
|
||||||
*/
|
*/
|
||||||
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
|
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
|
||||||
const z1 = toZonedTime(date1, timeZone);
|
const d1 = plainDate(date1);
|
||||||
const z2 = toZonedTime(date2, timeZone);
|
const d2 = plainDate(date2);
|
||||||
return isSameDayDateFns(z1, z2);
|
if (!d1 || !d2 || !timeZone) return false;
|
||||||
|
return isSameDayDateFns(toZonedTime(d1, timeZone), toZonedTime(d2, timeZone));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the numeric hour (0-23) for a date in a specific timezone.
|
* Gets the numeric hour (0-23) for a date in a specific timezone, or NaN when
|
||||||
|
* the date cannot be read.
|
||||||
*/
|
*/
|
||||||
export function getZonedHour(date: Date, timeZone: string): number {
|
export function getZonedHour(date: Date, timeZone: string): number {
|
||||||
return parseInt(formatInTimeZone(date, timeZone, 'H'), 10);
|
const d = plainDate(date);
|
||||||
|
if (!d || !timeZone) return NaN;
|
||||||
|
return parseInt(formatInTimeZone(d, timeZone, 'H'), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,21 +75,23 @@ export function getZonedHour(date: Date, timeZone: string): number {
|
|||||||
* or a formatted date string, all relative to the target timezone.
|
* or a formatted date string, all relative to the target timezone.
|
||||||
*/
|
*/
|
||||||
export function getRelativeDayLabel(date: Date, timeZone: string): string {
|
export function getRelativeDayLabel(date: Date, timeZone: string): string {
|
||||||
const now = new Date();
|
const d = plainDate(date);
|
||||||
const zonedDate = toZonedTime(date, timeZone);
|
if (!d || !timeZone) return '';
|
||||||
const zonedNow = toZonedTime(now, timeZone);
|
|
||||||
|
|
||||||
if (isSameDayDateFns(zonedDate, zonedNow)) return 'Today';
|
const zonedDate = toZonedTime(d, timeZone);
|
||||||
|
const zonedNow = toZonedTime(new Date(), timeZone);
|
||||||
|
|
||||||
|
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today();
|
||||||
|
|
||||||
const tomorrow = new Date(zonedNow);
|
const tomorrow = new Date(zonedNow);
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
if (isSameDayDateFns(zonedDate, tomorrow)) return 'Tomorrow';
|
if (isSameDayDateFns(zonedDate, tomorrow)) return m.day_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 'Yesterday';
|
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
|
||||||
|
|
||||||
return formatInTimeZone(date, timeZone, 'EEE d MMM');
|
return formatZoned(d, timeZone, 'EEE d MMM');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
import {
|
||||||
|
canStartViewTransition,
|
||||||
|
prefersReducedMotion,
|
||||||
|
skipActiveViewTransition,
|
||||||
|
startViewTransition,
|
||||||
|
supportsViewTransitions
|
||||||
|
} from './view-transition';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a day change inside a view transition, so the outgoing day is still on
|
||||||
|
* screen while the incoming one fades in - a real cross-fade rather than the
|
||||||
|
* old content vanishing and the new one fading up from nothing.
|
||||||
|
*
|
||||||
|
* Only the regions that actually change carry a `view-transition-name` (see
|
||||||
|
* `.day-region-*` in routes/layout.css); everything else - the strip, the
|
||||||
|
* header, the page chrome - is pinned by the `day-switch` class so it stays
|
||||||
|
* completely still.
|
||||||
|
*
|
||||||
|
* A day switch that lands while a navigation transition is still on screen just
|
||||||
|
* applies: starting a rival transition would skip the running one and flash the
|
||||||
|
* page (see view-transition.ts).
|
||||||
|
*/
|
||||||
|
/** Guards the shared cleanup below against a switch superseding a switch. */
|
||||||
|
let dayTransitionToken = 0;
|
||||||
|
|
||||||
|
export async function runDayTransition(update: () => void): Promise<void> {
|
||||||
|
if (!canStartViewTransition()) {
|
||||||
|
update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = ++dayTransitionToken;
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// The region snapshots include the part of the table normally scrolled up
|
||||||
|
// behind the sticky strip; the transition overlay is clipped at the bar's
|
||||||
|
// bottom edge so they cannot paint over the (live, clickable) strip.
|
||||||
|
// Measured per switch because the bar's height follows the scroll collapse.
|
||||||
|
const bar = document.querySelector('.daystrip .strip-row');
|
||||||
|
if (bar) {
|
||||||
|
const clip = Math.max(0, bar.getBoundingClientRect().bottom);
|
||||||
|
root.style.setProperty('--day-switch-clip', `${clip}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page stays scrollable during the fade, but the snapshots and the clip
|
||||||
|
// line above are anchored to where things were at capture - so the first
|
||||||
|
// sign of scrolling finishes the fade on the spot instead of animating
|
||||||
|
// against a moving page.
|
||||||
|
const skip = () => skipActiveViewTransition();
|
||||||
|
window.addEventListener('wheel', skip, { passive: true });
|
||||||
|
window.addEventListener('touchmove', skip, { passive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Svelte applies the change on the next tick; the transition has to wait
|
||||||
|
// for that before it snapshots the new state. `day-switch` scopes which
|
||||||
|
// regions take part (see routes/layout.css) and is cleared when it ends.
|
||||||
|
await startViewTransition(
|
||||||
|
async () => {
|
||||||
|
update();
|
||||||
|
await tick();
|
||||||
|
},
|
||||||
|
{ rootClass: 'day-switch' }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener('wheel', skip);
|
||||||
|
window.removeEventListener('touchmove', skip);
|
||||||
|
// a newer switch owns the clip var now; only the last one may clear it
|
||||||
|
if (token === dayTransitionToken) root.style.removeProperty('--day-switch-clip');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for browsers without view transitions: fade the block back in when
|
||||||
|
* the value passed to it changes. Animates the existing node rather than
|
||||||
|
* remounting it, so the canvas charts keep their zoom state.
|
||||||
|
*/
|
||||||
|
export function daySwap(node: HTMLElement, key: unknown) {
|
||||||
|
let current = key;
|
||||||
|
|
||||||
|
const play = () => {
|
||||||
|
// view transitions handle it properly where they exist
|
||||||
|
if (supportsViewTransitions() || prefersReducedMotion()) return;
|
||||||
|
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
|
||||||
|
duration: 460,
|
||||||
|
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: unknown) {
|
||||||
|
if (next === current) return;
|
||||||
|
current = next;
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
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) => {
|
||||||
@@ -11,7 +13,14 @@ 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;
|
||||||
|
|
||||||
export function buildLocationRoute(location: GeoLocation): string {
|
/** Only the fields the route is built from, so callers holding a partial record
|
||||||
|
* (the nearby-cities list, for one) don't have to fake a whole GeoLocation. */
|
||||||
|
export type RoutableLocation = Pick<
|
||||||
|
GeoLocation,
|
||||||
|
'id' | 'name' | 'latitude' | 'longitude' | 'feature_code' | 'population'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function buildLocationRoute(location: RoutableLocation): string {
|
||||||
// coordinate-only locations (GPS) have no real geocoding id
|
// 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`;
|
||||||
@@ -67,6 +76,16 @@ interface ResolveLocationOptions {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocoding results for a route segment, kept for the life of the process.
|
||||||
|
*
|
||||||
|
* A city's coordinates do not change, and the same segment is resolved over and
|
||||||
|
* over: once per per-location route during the prerender (five builds of the
|
||||||
|
* same lookup for every city), and again on every client-side hop from a city's
|
||||||
|
* week page to its comparison or archive.
|
||||||
|
*/
|
||||||
|
const resolvedLocations = new Map<string, GeoLocation>();
|
||||||
|
|
||||||
export async function resolveLocationFromRoute({
|
export async function resolveLocationFromRoute({
|
||||||
urlLocation,
|
urlLocation,
|
||||||
routePrefix,
|
routePrefix,
|
||||||
@@ -77,6 +96,11 @@ export async function resolveLocationFromRoute({
|
|||||||
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
|
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The canonical-path check below still has to run per call (the same city is
|
||||||
|
// reached under different route prefixes), so only the lookup is cached.
|
||||||
|
const cached = resolvedLocations.get(urlLocation);
|
||||||
|
if (cached) return finishResolve(cached, routePrefix, event);
|
||||||
|
|
||||||
let urlLocationName: string;
|
let urlLocationName: string;
|
||||||
let urlLocationId: string | undefined;
|
let urlLocationId: string | undefined;
|
||||||
|
|
||||||
@@ -115,12 +139,23 @@ export async function resolveLocationFromRoute({
|
|||||||
location = candidate;
|
location = candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvedLocations.set(urlLocation, location);
|
||||||
|
return finishResolve(location, routePrefix, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishResolve(
|
||||||
|
location: GeoLocation,
|
||||||
|
routePrefix: string,
|
||||||
|
event: ResolveLocationOptions['event']
|
||||||
|
): GeoLocation {
|
||||||
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
|
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
|
||||||
// every path with a trailing slash. Match that here or the equality check
|
// every path with a trailing slash. Match that here or the equality check
|
||||||
// never holds and the redirect loops forever.
|
// never holds and the redirect loops forever. The comparison also has to
|
||||||
|
// ignore the locale prefix the URL carries, while the redirect keeps it -
|
||||||
|
// otherwise every localized URL would bounce back to English.
|
||||||
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
|
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
|
||||||
if (event.url.pathname !== canonicalPath) {
|
if (deLocalizeHref(event.url.pathname) !== canonicalPath) {
|
||||||
throw redirect(303, canonicalPath);
|
throw redirect(303, localizeHref(canonicalPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
return location;
|
return location;
|
||||||
|
|||||||
@@ -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, bom, google, UKMO ensembles)
|
* the map does not serve at all (best_match, google, UKMO ensembles)
|
||||||
* resolve to null.
|
* resolve to null.
|
||||||
*/
|
*/
|
||||||
export const modelDomainCandidates: Record<string, string[]> = {
|
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,9 +46,8 @@ export 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'],
|
||||||
|
|
||||||
// KMA Korea (kma_ldps is not served by the maps project)
|
// CHMI Czech Republic (Central Europe is the widest native domain)
|
||||||
kma_seamless: ['kma_gdps'],
|
chmi_aladin_seamless: ['chmi_aladin_seamless', 'chmi_aladin_central_europe_2km'],
|
||||||
kma_ldps: ['kma_gdps'],
|
|
||||||
|
|
||||||
// JMA Japan
|
// JMA Japan
|
||||||
jma_seamless: ['jma_seamless', 'jma_gsm'],
|
jma_seamless: ['jma_seamless', 'jma_gsm'],
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared "now" clock.
|
||||||
|
*
|
||||||
|
* One timer, aligned to the wall-clock minute, drives every current-time marker
|
||||||
|
* in the app (the hourly table's NOW line, the meteogram's time marker) so they
|
||||||
|
* stay accurate without a page reload.
|
||||||
|
*
|
||||||
|
* The timer only runs while at least one component is subscribed, and re-aligns
|
||||||
|
* after each tick so it can't drift or fire twice within a minute (e.g. after
|
||||||
|
* the tab has been suspended).
|
||||||
|
*/
|
||||||
|
|
||||||
|
let current = $state(new Date());
|
||||||
|
let subscribers = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const MINUTE_MS = 60_000;
|
||||||
|
|
||||||
|
function scheduleTick() {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => {
|
||||||
|
current = new Date();
|
||||||
|
scheduleTick();
|
||||||
|
},
|
||||||
|
MINUTE_MS - (Date.now() % MINUTE_MS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive current time, refreshed on every minute boundary while the calling
|
||||||
|
* component is mounted. On the server it just reports render time.
|
||||||
|
*/
|
||||||
|
export function useNow(): { readonly current: Date } {
|
||||||
|
if (browser) {
|
||||||
|
$effect(() => {
|
||||||
|
if (subscribers++ === 0) {
|
||||||
|
current = new Date();
|
||||||
|
scheduleTick();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (--subscribers === 0) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get current() {
|
||||||
|
return browser ? current : new Date();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { TransitionConfig } from 'svelte/transition';
|
||||||
|
|
||||||
|
const prefersReducedMotion = () =>
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fades a loading placeholder out *over* the content that replaces it.
|
||||||
|
*
|
||||||
|
* A plain `out:fade` is not usable on a skeleton: Svelte keeps the node in the
|
||||||
|
* layout for the length of the transition, so the real content stacks below the
|
||||||
|
* placeholder and the whole page jumps up the moment the placeholder finally
|
||||||
|
* unmounts. That is why these placeholders only ever faded in - the swap was
|
||||||
|
* abrupt, but at least nothing moved.
|
||||||
|
*
|
||||||
|
* This pins the placeholder to the box it already occupies and takes it out of
|
||||||
|
* flow for the fade instead. The content settles into its final position
|
||||||
|
* immediately and the skeleton dissolves on top of it.
|
||||||
|
*
|
||||||
|
* Requirements at the call site: the placeholder's parent must be positioned
|
||||||
|
* (`class="relative"`) and must be the same element that renders the real
|
||||||
|
* content, or the overlay lands somewhere else on the page.
|
||||||
|
*/
|
||||||
|
export function skeletonOut(node: HTMLElement, { duration = 260 } = {}): TransitionConfig {
|
||||||
|
if (prefersReducedMotion()) return { duration: 0 };
|
||||||
|
|
||||||
|
// measured while the node is still in flow, which is when Svelte builds the
|
||||||
|
// transition - the values below are what freeze it in place
|
||||||
|
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node;
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
css: (t) => `
|
||||||
|
opacity: ${t};
|
||||||
|
position: absolute;
|
||||||
|
top: ${offsetTop}px;
|
||||||
|
left: ${offsetLeft}px;
|
||||||
|
width: ${offsetWidth}px;
|
||||||
|
height: ${offsetHeight}px;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
`
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Mirrors UI state into the query string so a view can be linked and reloaded
|
||||||
|
* exactly as it was: which day is open, which model is plotted, which variables
|
||||||
|
* are compared.
|
||||||
|
*
|
||||||
|
* Writes use `replaceState` rather than `goto`, so mirroring state never adds a
|
||||||
|
* history entry or re-runs a load - the back button still means "the page
|
||||||
|
* before", not "the previous day I clicked".
|
||||||
|
*
|
||||||
|
* The base is `location`, deliberately not `page.url`. Shallow routing does not
|
||||||
|
* republish the URL: `replaceState` writes the history entry (and files the
|
||||||
|
* *previous* `page.url` in it, so a popstate can restore it) but leaves
|
||||||
|
* `page.url` on the last navigated URL. Diffing against that stale value is
|
||||||
|
* wrong in exactly one direction - clearing a parameter. Opening a day writes
|
||||||
|
* `?day=`, `page.url` still has none, so asking to remove it produces a URL
|
||||||
|
* identical to the stale one, the write is skipped as a no-op, and the
|
||||||
|
* parameter stays in the address bar for good.
|
||||||
|
*
|
||||||
|
* Reading `location` rather than a passed-in URL also removes the old trap that
|
||||||
|
* callers had to pass it untracked: an effect that both read `$page.url` and
|
||||||
|
* wrote to it looped until `effect_update_depth_exceeded` hung the page.
|
||||||
|
*/
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { replaceState } from '$app/navigation';
|
||||||
|
|
||||||
|
export function syncSearchParams(updates: Record<string, string | null>): void {
|
||||||
|
if (!browser) return;
|
||||||
|
const current = new URL(window.location.href);
|
||||||
|
const next = new URL(current);
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value == null || value === '') next.searchParams.delete(key);
|
||||||
|
else next.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
if (next.href === current.href) return;
|
||||||
|
try {
|
||||||
|
replaceState(next, {});
|
||||||
|
} catch {
|
||||||
|
// A page whose state settles during mount can get here before the router
|
||||||
|
// has taken over. The URL is cosmetic, so retry on the next frame rather
|
||||||
|
// than letting it break the page.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
replaceState(next, {});
|
||||||
|
} catch {
|
||||||
|
/* give up: the view still works, it just isn't linkable yet */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value to mirror, or null when it is the view's default. Defaults belong
|
||||||
|
* in the code, not the query string: a shared link should carry only what the
|
||||||
|
* visitor actually changed, and going back to the default has to clear the
|
||||||
|
* parameter again rather than pin the default in place.
|
||||||
|
*/
|
||||||
|
export function unlessDefault(value: string | null | undefined, fallback: string): string | null {
|
||||||
|
return value && value !== fallback ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same, for the comma-separated list parameters. Order counts - the models are
|
||||||
|
* plotted, coloured and legended in the order they are listed, so a reordered
|
||||||
|
* line-up is a different view even when it holds the same entries.
|
||||||
|
*/
|
||||||
|
export function listUnlessDefault(
|
||||||
|
values: string[] | null | undefined,
|
||||||
|
fallback: string[]
|
||||||
|
): string | null {
|
||||||
|
if (!values?.length) return null;
|
||||||
|
const joined = values.join(',');
|
||||||
|
return joined === fallback.join(',') ? null : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a comma-separated list, dropping empties. */
|
||||||
|
export function readList(url: URL, key: string): string[] | null {
|
||||||
|
const raw = url.searchParams.get(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const list = raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return list.length > 0 ? list : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Central entry point for every view transition in the app.
|
||||||
|
*
|
||||||
|
* Two things it exists to get right.
|
||||||
|
*
|
||||||
|
* **One transition at a time, but only where it matters.** Starting a
|
||||||
|
* transition while another is running makes the browser skip the first, and
|
||||||
|
* what that looks like depends entirely on which phase the first is in:
|
||||||
|
*
|
||||||
|
* - *capturing* - its update callback has not resolved yet, so the screen is
|
||||||
|
* frozen on the outgoing snapshot. Skipping drops that snapshot on the spot
|
||||||
|
* and pops the half-updated live DOM into view: the whole-screen flash.
|
||||||
|
* This phase is common here, because navigation deliberately holds the
|
||||||
|
* callback open while the new page fetches, and `/` plus every bare
|
||||||
|
* `/weather/<view>/` page is a redirect stub that navigates again from
|
||||||
|
* `onMount` - one click, two or three navigations.
|
||||||
|
* - *animating* - the DOM is already in its final state and the pseudo
|
||||||
|
* elements are playing out. Skipping just finishes them early, landing on
|
||||||
|
* exactly the state they were heading for.
|
||||||
|
*
|
||||||
|
* So a new transition rides along inside the running one only while it is
|
||||||
|
* capturing; once it is animating, superseding it is the better answer (waiting
|
||||||
|
* would strand the new update under a stale snapshot until the animation ends).
|
||||||
|
*
|
||||||
|
* **A scoping class.** `rootClass` is set on `<html>` for the life of the
|
||||||
|
* transition, so the stylesheet can tell a page swap from a day switch and pin
|
||||||
|
* the parts that are identical on both sides (see routes/layout.css).
|
||||||
|
*/
|
||||||
|
type UpdateCallback = () => void | Promise<void>;
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** Class set on `<html>` while the transition runs, for scoping CSS. */
|
||||||
|
rootClass?: string;
|
||||||
|
/**
|
||||||
|
* Set false to run the update without a transition. For content the browser
|
||||||
|
* does not paint into a snapshot - a cross-origin iframe - where animating
|
||||||
|
* means animating a hole rather than a cross-fade.
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
|
||||||
|
let capturing: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many running transitions hold each scoping class. A superseded
|
||||||
|
* transition's cleanup fires while its successor is mid-capture (skipping
|
||||||
|
* rejects `finished` on a microtask, which runs before the next render);
|
||||||
|
* without the count it would strip the class out from under the successor,
|
||||||
|
* and a day switch captured without `day-switch` falls back to the full-page
|
||||||
|
* fade it exists to prevent.
|
||||||
|
*/
|
||||||
|
const rootClassHolds = new Map<string, number>();
|
||||||
|
|
||||||
|
/** The most recently started transition, while it is capturing or animating. */
|
||||||
|
let active: ViewTransition | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finishes the running transition's animation on the spot (the DOM is already
|
||||||
|
* in its final state, so this is always safe). Used to hand the screen back
|
||||||
|
* the moment the user starts scrolling under a day switch, rather than
|
||||||
|
* animating against a moving target.
|
||||||
|
*/
|
||||||
|
export function skipActiveViewTransition(): void {
|
||||||
|
active?.skipTransition();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supportsViewTransitions = (): boolean =>
|
||||||
|
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
|
||||||
|
|
||||||
|
export const prefersReducedMotion = (): boolean =>
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while a transition is frozen on its outgoing snapshot - the phase in
|
||||||
|
* which starting a rival transition would flash the page.
|
||||||
|
*/
|
||||||
|
export const isViewTransitionCapturing = (): boolean => capturing !== null;
|
||||||
|
|
||||||
|
/** Whether `startViewTransition` would actually open one right now. */
|
||||||
|
export const canStartViewTransition = (): boolean =>
|
||||||
|
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionCapturing();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `update` inside a view transition, or straight away when one cannot (or
|
||||||
|
* must not) be started. Resolves once the transition has finished animating -
|
||||||
|
* or as soon as the update is done, when it ran on its own.
|
||||||
|
*/
|
||||||
|
export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
|
||||||
|
const { rootClass, enabled = true } = options;
|
||||||
|
|
||||||
|
if (!enabled || !canStartViewTransition()) {
|
||||||
|
return Promise.resolve(update()).then(
|
||||||
|
() => {},
|
||||||
|
(error: unknown) => {
|
||||||
|
console.error('view transition update failed', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (rootClass) {
|
||||||
|
rootClassHolds.set(rootClass, (rootClassHolds.get(rootClass) ?? 0) + 1);
|
||||||
|
root.classList.add(rootClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transition = document.startViewTransition(update);
|
||||||
|
active = transition;
|
||||||
|
|
||||||
|
// A throw inside the callback rejects `updateCallbackDone` as well as
|
||||||
|
// `finished`. Nothing awaits the former, and an unhandled rejection there is
|
||||||
|
// what turns one bad render into an "Uncaught" error on the page.
|
||||||
|
const captured = transition.updateCallbackDone.then(
|
||||||
|
() => {},
|
||||||
|
(error: unknown) => {
|
||||||
|
console.error('view transition update failed', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
capturing = captured;
|
||||||
|
void captured.then(() => {
|
||||||
|
if (capturing === captured) capturing = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A superseded transition rejects `finished`; the DOM is already up to date,
|
||||||
|
// so that is not an error worth surfacing.
|
||||||
|
return transition.finished
|
||||||
|
.then(
|
||||||
|
() => {},
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
if (active === transition) active = null;
|
||||||
|
if (!rootClass) return;
|
||||||
|
const holds = (rootClassHolds.get(rootClass) ?? 1) - 1;
|
||||||
|
if (holds > 0) {
|
||||||
|
rootClassHolds.set(rootClass, holds);
|
||||||
|
} else {
|
||||||
|
rootClassHolds.delete(rootClass);
|
||||||
|
root.classList.remove(rootClass);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+313
-9
@@ -1,14 +1,31 @@
|
|||||||
<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 {
|
||||||
|
mapTransitionCover,
|
||||||
|
markPageLoading,
|
||||||
|
markPageReady,
|
||||||
|
pageContentReady
|
||||||
|
} from '$lib/stores/page-transition.svelte';
|
||||||
import { storedTheme } from '$lib/stores/settings';
|
import { storedTheme } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import {
|
||||||
|
canStartViewTransition,
|
||||||
|
startViewTransition,
|
||||||
|
supportsViewTransitions
|
||||||
|
} from '$lib/utils/view-transition';
|
||||||
|
|
||||||
|
import Footer from '$lib/components/navigation/footer.svelte';
|
||||||
import Header from '$lib/components/navigation/header.svelte';
|
import Header from '$lib/components/navigation/header.svelte';
|
||||||
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
||||||
|
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import { routePath } from '$lib/i18n';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
|
|
||||||
@@ -16,20 +33,242 @@
|
|||||||
|
|
||||||
// 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);
|
||||||
document.documentElement.classList.toggle('dark', dark);
|
const paint = () => {
|
||||||
|
root.classList.toggle('dark', dark);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The very first application is just painting the stored theme - only
|
||||||
|
// an actual switch afterwards is worth cross-fading.
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (!themeSettled || reduced) {
|
||||||
|
themeSettled = true;
|
||||||
|
paint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canStartViewTransition() && !onMapsPage()) {
|
||||||
|
// one cross-fade of the whole document; component transitions untouched
|
||||||
|
void startViewTransition(paint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A navigation transition already owns the screen: repaint under it
|
||||||
|
// rather than skipping it (which would flash), and fall back to the
|
||||||
|
// colour transition below when the browser has none at all.
|
||||||
|
if (supportsViewTransitions()) {
|
||||||
|
paint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no view transitions: fall back to fading the colours for one window
|
||||||
|
root.classList.add('theme-transition');
|
||||||
|
clearTimeout(themeTimer);
|
||||||
|
themeTimer = window.setTimeout(() => root.classList.remove('theme-transition'), 400);
|
||||||
|
paint();
|
||||||
};
|
};
|
||||||
|
|
||||||
apply();
|
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
|
||||||
|
// held open for a short grace period, waiting for the new page to report that
|
||||||
|
// its data has landed. A cached or fast response lands inside that window and
|
||||||
|
// the old page fades straight into the finished one, no skeleton in between.
|
||||||
|
//
|
||||||
|
// Past the grace period the wait gives up and the loading overlay takes over:
|
||||||
|
// holding the old page frozen any longer looks like a dead click, and the
|
||||||
|
// overlay is the honest answer - something is happening, it just isn't here
|
||||||
|
// yet. Note the order: the overlay has to be in the DOM *before* the
|
||||||
|
// transition captures the incoming state, because a running view transition
|
||||||
|
// freezes the page and nothing painted after that point can appear.
|
||||||
|
//
|
||||||
|
// There is deliberately no fade for browsers without view transitions. The
|
||||||
|
// old fallback dimmed the outgoing page to nothing and brought the new one
|
||||||
|
// back up, which on a single layer is a flash of the bare background rather
|
||||||
|
// than a cross-fade. Swapping outright and letting the overlay carry the
|
||||||
|
// "loading" message is quieter and honest.
|
||||||
|
const READY_HOLD_MS = 350;
|
||||||
|
// Nothing reports ready when a fetch fails outright, so the overlay needs its
|
||||||
|
// own way out rather than sitting on top of an error message forever.
|
||||||
|
const OVERLAY_CEILING_MS = 15000;
|
||||||
|
|
||||||
|
// The map is a cross-origin iframe, and a browser does not paint one into a
|
||||||
|
// view transition snapshot - captured bare, any transition with the maps page
|
||||||
|
// on either side would animate a hole where the map is. The way out is to
|
||||||
|
// make sure the map is never what gets captured: the maps page keeps an
|
||||||
|
// opaque cover over the iframe while the map boots (so an arrival fades into
|
||||||
|
// a clean panel, and the map fades up once ready), and raises the same cover
|
||||||
|
// again just before a departure is captured (see mapTransitionCover). Both
|
||||||
|
// snapshots then hold real pixels and the maps page transitions like any
|
||||||
|
// other route.
|
||||||
|
const MAPS_ROUTE = '/weather/maps';
|
||||||
|
const onMapsPage = () => routePath(get(page).url.pathname).startsWith(MAPS_ROUTE);
|
||||||
|
|
||||||
|
let loadingOverlay = $state(false);
|
||||||
|
let overlayCeilingTimer = 0;
|
||||||
|
// A view transition freezes the page, so a Svelte in-transition started under
|
||||||
|
// one cannot play - the overlay would be captured at opacity 0 and pop in
|
||||||
|
// afterwards. Inside a transition the cross-fade does the fading instead.
|
||||||
|
let overlayFadesIn = $state(true);
|
||||||
|
|
||||||
|
function showOverlay(animate: boolean): void {
|
||||||
|
overlayFadesIn = animate;
|
||||||
|
loadingOverlay = true;
|
||||||
|
clearTimeout(overlayCeilingTimer);
|
||||||
|
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideOverlay(): void {
|
||||||
|
clearTimeout(overlayCeilingTimer);
|
||||||
|
loadingOverlay = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual dismissal. The overlay reports on a fetch it does not control, so
|
||||||
|
* "stuck" is always a possibility (a page that never reports ready, a request
|
||||||
|
* that neither resolves nor rejects) - and the page behind it still works.
|
||||||
|
* Whatever was loading carries on; only the veil goes.
|
||||||
|
*/
|
||||||
|
function dismissOverlay(): void {
|
||||||
|
hideOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape' && loadingOverlay) dismissOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page that just mounted has its data: whatever we were waiting for is in. */
|
||||||
|
$effect(() => {
|
||||||
|
if ($pageContentReady) hideOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes that are not finished on arrival: the five that fetch a forecast
|
||||||
|
// after mounting, the maps page (whose map is a cross-origin iframe that has
|
||||||
|
// to load first), and the redirect stubs, which render nothing at all and
|
||||||
|
// bounce to a located URL from `onMount`. Knowing this up front is what makes
|
||||||
|
// the wait reliable - the layout clears the ready flag before the swap rather
|
||||||
|
// than trusting the incoming page to have done it.
|
||||||
|
const PENDING_ROUTES = new Set([
|
||||||
|
'/',
|
||||||
|
'/weather/week',
|
||||||
|
'/weather/14-day',
|
||||||
|
'/weather/compare',
|
||||||
|
'/weather/seasonal',
|
||||||
|
'/weather/historical',
|
||||||
|
'/weather/maps',
|
||||||
|
'/weather/week/[location]',
|
||||||
|
'/weather/14-day/[location]',
|
||||||
|
'/weather/compare/[location]',
|
||||||
|
'/weather/seasonal/[location]',
|
||||||
|
'/weather/historical/[location]'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves once the freshly mounted page has its data, or once the grace
|
||||||
|
* period is up - in which case the overlay goes up first, so it is part of
|
||||||
|
* the state the transition is about to snapshot.
|
||||||
|
*/
|
||||||
|
async function waitForContent(underTransition: boolean): Promise<void> {
|
||||||
|
const deadline = Date.now() + READY_HOLD_MS;
|
||||||
|
while (!get(pageContentReady) && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
if (!get(pageContentReady)) showOverlay(!underTransition);
|
||||||
|
// one more frame so the page paints its data before the snapshot is taken
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the navigation lands on the route and params the page is already
|
||||||
|
* showing - the sidebar's home link from the week page it points at, or a
|
||||||
|
* link that differs only in the query string.
|
||||||
|
*
|
||||||
|
* SvelteKit keeps the page component mounted for those, and nothing it holds
|
||||||
|
* changes: its forecast is already fetched, so the readiness effect it
|
||||||
|
* registered never re-runs and never re-announces. Clearing the flag for such
|
||||||
|
* a navigation strands it cleared, and the overlay sits there until its
|
||||||
|
* ceiling. There is genuinely nothing to wait for, so don't clear it.
|
||||||
|
*/
|
||||||
|
function landsOnCurrentPage(navigation: {
|
||||||
|
from: { route: { id: string | null }; params: Record<string, string> | null } | null;
|
||||||
|
to: { route: { id: string | null }; params: Record<string, string> | null } | null;
|
||||||
|
}): boolean {
|
||||||
|
const from = navigation.from;
|
||||||
|
const to = navigation.to;
|
||||||
|
if (!from?.route.id || from.route.id !== to?.route.id) return false;
|
||||||
|
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
onNavigate(async (navigation) => {
|
||||||
|
const pending =
|
||||||
|
PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
|
||||||
|
// Either way the flag is set explicitly: leaving a page that never resolved
|
||||||
|
// for one that has nothing to load would otherwise strand the overlay.
|
||||||
|
if (pending) markPageLoading();
|
||||||
|
else markPageReady();
|
||||||
|
|
||||||
|
// `startViewTransition` decides whether a transition is possible at all
|
||||||
|
// (support, reduced motion, one already capturing) and runs the update
|
||||||
|
// inline when it is not - so there is exactly one path from here down.
|
||||||
|
const underTransition = canStartViewTransition();
|
||||||
|
|
||||||
|
// Leaving the maps page: cover the iframe before the outgoing state is
|
||||||
|
// captured, so the snapshot holds a clean panel instead of a hole where
|
||||||
|
// the map was. The tick makes sure the cover is actually in the DOM by
|
||||||
|
// the time the capture reads it.
|
||||||
|
if (underTransition && navigation.from?.route?.id === MAPS_ROUTE) {
|
||||||
|
mapTransitionCover.set(true);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((swap) => {
|
||||||
|
void startViewTransition(
|
||||||
|
async () => {
|
||||||
|
// hand control back so SvelteKit swaps the DOM underneath the
|
||||||
|
// frozen snapshot of the old page
|
||||||
|
swap();
|
||||||
|
// A superseded navigation (a redirect, or a fast second click)
|
||||||
|
// rejects this promise; that is not an error worth surfacing,
|
||||||
|
// and leaving it unhandled shows up as "navigation aborted".
|
||||||
|
await navigation.complete.catch(() => {});
|
||||||
|
if (pending) await waitForContent(underTransition);
|
||||||
|
},
|
||||||
|
// pins the chrome that is the same on both sides (routes/layout.css)
|
||||||
|
{ rootClass: 'page-switch', enabled: underTransition }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let mainEl = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
afterNavigate((navigation) => {
|
||||||
|
// The departure snapshot (if any) is taken by now, so the maps cover has
|
||||||
|
// done its job; lowering it here also means a later visit to the maps page
|
||||||
|
// starts from its own boot cover rather than a stuck one.
|
||||||
|
mapTransitionCover.set(false);
|
||||||
|
|
||||||
|
// The page scrolls inside <main>, not the window, so SvelteKit's own scroll
|
||||||
|
// handling never touches it and a new page would open half way down.
|
||||||
|
// Back/forward and in-page anchors keep their position.
|
||||||
|
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
|
||||||
|
mainEl?.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// the maps page embeds a full-bleed map: no padding, no scrolling
|
// the maps page embeds a full-bleed map: no padding, no scrolling
|
||||||
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
|
let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps'));
|
||||||
|
|
||||||
let sidebarCollapsed = $state(false);
|
let sidebarCollapsed = $state(false);
|
||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
@@ -48,14 +287,66 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<!-- the icon itself lives in app.html, so the SPA fallback carries it too -->
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
|
||||||
|
indicator, not a modal, so the nav and the search stay usable while a slow
|
||||||
|
forecast is still on its way. It is also always dismissable - Escape or the
|
||||||
|
close button - because a veil nobody can get rid of is worse than no veil,
|
||||||
|
and the page underneath is perfectly usable either way. -->
|
||||||
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|
||||||
|
{#if loadingOverlay}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
|
||||||
|
in:fade={{ duration: overlayFadesIn ? 120 : 0 }}
|
||||||
|
out:fade={{ duration: 280 }}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pointer-events-auto absolute top-3 right-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:bg-muted hover:text-foreground md:top-4 md:right-4"
|
||||||
|
onclick={dismissOverlay}
|
||||||
|
aria-label={m.page_loading_dismiss()}
|
||||||
|
title={m.page_loading_dismiss()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 animate-spin text-primary"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-semibold">{m.page_loading()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<!-- Desktop sidebar -->
|
<!-- Desktop sidebar -->
|
||||||
<div class="hidden h-full shrink-0 md:block">
|
<div class="sidebar-region hidden h-full shrink-0 md:block">
|
||||||
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
|
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,17 +373,30 @@
|
|||||||
<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
|
||||||
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 lg:px-8 lg:py-6'}
|
bind:this={mainEl}
|
||||||
|
class={fullBleed
|
||||||
|
? 'flex-1 overflow-hidden'
|
||||||
|
: 'flex flex-1 flex-col overflow-y-auto p-3 lg:px-8 lg:py-6'}
|
||||||
>
|
>
|
||||||
{#if fullBleed}
|
{#if fullBleed}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- cap the content width on very large screens; generous bottom room
|
<!-- cap the content width on very large screens; the footer below
|
||||||
so the last chart/table never sits flush against the viewport edge -->
|
gives the page its ending, so only modest bottom room is needed -->
|
||||||
<div class="mx-auto w-full max-w-[1536px] pb-80">
|
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
|
||||||
{@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>
|
||||||
|
|||||||
+16
-3
@@ -1,3 +1,16 @@
|
|||||||
<svelte:head>
|
<script lang="ts">
|
||||||
<title>Drizzli</title>
|
import { onMount } from 'svelte';
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
|
||||||
throw redirect(303, '/weather/week/');
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LocalizedContent from '$lib/components/localized-content.svelte';
|
||||||
|
|
||||||
|
import de from './content/de.svelte';
|
||||||
|
import en from './content/en.svelte';
|
||||||
|
import es from './content/es.svelte';
|
||||||
|
import fr from './content/fr.svelte';
|
||||||
|
import it from './content/it.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LocalizedContent variants={{ en, de, es, fr, it }} />
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Über Drizz.li" subtitle="Eine kleine, quelloffene Wetterseite.">
|
||||||
|
<p>
|
||||||
|
Drizz.li ist eine unabhängige Wetterseite. Sie zeigt Vorhersagen mehrerer Modelle,
|
||||||
|
Modellvergleiche, 14-Tage-Aussichten, Wetterkarten und historisches Wetter - ohne Konten,
|
||||||
|
Werbung oder Tracker.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>So funktioniert es</h2>
|
||||||
|
<p>
|
||||||
|
Alle Wetterdaten stammen aus den hervorragenden Open-Data-Schnittstellen von
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, die nationale
|
||||||
|
Wetterdienste bündeln (DWD, NOAA, Météo-France, MeteoSchweiz und weitere). Drizz.li ist eine
|
||||||
|
statische Seite: Die Vorhersagen werden direkt von Ihrem Browser abgerufen und auf Ihrem Gerät
|
||||||
|
dargestellt.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Die Seite ist quelloffen. Jede und jeder kann den Code lesen, Fehler melden oder Verbesserungen
|
||||||
|
beitragen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Das Projekt unterstützen</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li ist kostenlos nutzbar. Wenn es Ihnen nützt, können Sie es mit einem kleinen monatlichen
|
||||||
|
Beitrag unterstützen (ab 3 € / Monat) - das hilft, Domain, Hosting und Entwicklungszeit zu
|
||||||
|
decken. Als Dankeschön schalten Unterstützerinnen und Unterstützer die Extras frei: historisches
|
||||||
|
Wetter mit Vergleich zu den Klimanormalen, die saisonalen Aussichten für die kommenden Monate
|
||||||
|
sowie neue Funktionen, sobald sie erscheinen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Beiträge laufen über eine einfache Banküberweisung - kein Zahlungsdienstleister, keine
|
||||||
|
gespeicherten Kartendaten. Wie das genau abläuft, steht in den
|
||||||
|
<a href={href('/legal/terms')}>Unterstützerbedingungen</a> und in der
|
||||||
|
<a href={href('/legal/privacy')}>Datenschutzerklärung</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Kontakt</h2>
|
||||||
|
<p>
|
||||||
|
Fragen, Rückmeldungen oder Fehlerberichte:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="About Drizz.li" subtitle="A small, open-source weather site.">
|
||||||
|
<p>
|
||||||
|
Drizz.li is an independent weather site. It shows multi-model forecasts, model comparisons,
|
||||||
|
14-day outlooks, weather maps and historical weather - without accounts, ads or trackers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>How it works</h2>
|
||||||
|
<p>
|
||||||
|
All weather data comes from the excellent open-data APIs of
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, which aggregates
|
||||||
|
national weather services (DWD, NOAA, Météo-France, MeteoSwiss and more). Drizz.li is a static
|
||||||
|
site: forecasts are fetched directly by your browser and rendered on your device.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The site is open source. Anyone can read the code, report issues or contribute improvements.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Supporting the project</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li is free to use. If you find it useful, you can support it with a small monthly
|
||||||
|
contribution (from €3 / month) - it helps cover the domain, hosting and development time. As a
|
||||||
|
thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, the
|
||||||
|
seasonal outlook for the months ahead, and new supporter features as they land.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Contributions are handled by a simple bank transfer - no payment processor, no stored card
|
||||||
|
details. The details of how that works are in the
|
||||||
|
<a href={href('/legal/terms')}>supporter terms</a> and the
|
||||||
|
<a href={href('/legal/privacy')}>privacy policy</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions, feedback or bug reports:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Sobre Drizz.li" subtitle="Un pequeño sitio meteorológico de código abierto.">
|
||||||
|
<p>
|
||||||
|
Drizz.li es un sitio meteorológico independiente. Muestra pronósticos de varios modelos,
|
||||||
|
comparaciones entre modelos, previsiones a 14 días, mapas meteorológicos y datos históricos, sin
|
||||||
|
cuentas, sin anuncios y sin rastreadores.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Cómo funciona</h2>
|
||||||
|
<p>
|
||||||
|
Todos los datos proceden de las excelentes API de datos abiertos de
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, que agrega los
|
||||||
|
servicios meteorológicos nacionales (DWD, NOAA, Météo-France, MeteoSwiss y más). Drizz.li es un
|
||||||
|
sitio estático: tu navegador descarga los pronósticos directamente y los representa en tu
|
||||||
|
dispositivo.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
El sitio es de código abierto. Cualquiera puede leer el código, informar de errores o contribuir
|
||||||
|
con mejoras.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Apoyar el proyecto</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li es gratuito. Si te resulta útil, puedes apoyarlo con una pequeña aportación mensual
|
||||||
|
(desde 3 € al mes): ayuda a cubrir el dominio, el alojamiento y el tiempo de desarrollo. Como
|
||||||
|
agradecimiento, quienes colaboran desbloquean los extras: clima histórico con comparación frente
|
||||||
|
a las normales climáticas, la perspectiva estacional de los próximos meses y las nuevas
|
||||||
|
funciones que vayan llegando.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Las aportaciones se gestionan mediante una simple transferencia bancaria: sin pasarela de pago y
|
||||||
|
sin guardar datos de tarjetas. Los detalles están en las
|
||||||
|
<a href={href('/legal/terms')}>condiciones para colaboradores</a> y en la
|
||||||
|
<a href={href('/legal/privacy')}>política de privacidad</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contacto</h2>
|
||||||
|
<p>
|
||||||
|
Dudas, comentarios o informes de errores:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="À propos de Drizz.li" subtitle="Un petit site météo open source.">
|
||||||
|
<p>
|
||||||
|
Drizz.li est un site météo indépendant. Il propose des prévisions multi-modèles, des
|
||||||
|
comparaisons de modèles, des tendances à 14 jours, des cartes météo et des données historiques,
|
||||||
|
sans compte, sans publicité et sans traceurs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Comment ça marche</h2>
|
||||||
|
<p>
|
||||||
|
Toutes les données proviennent des excellentes API ouvertes d'<a
|
||||||
|
href="https://open-meteo.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener">Open-Meteo</a
|
||||||
|
>, qui agrègent les services météorologiques nationaux (DWD, NOAA, Météo-France, MeteoSwiss et
|
||||||
|
d'autres). Drizz.li est un site statique : les prévisions sont récupérées directement par votre
|
||||||
|
navigateur et affichées sur votre appareil.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Le site est open source. Chacun peut lire le code, signaler un problème ou proposer des
|
||||||
|
améliorations.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Soutenir le projet</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li est gratuit. S'il vous est utile, vous pouvez le soutenir par une petite contribution
|
||||||
|
mensuelle (à partir de 3 € par mois) : elle aide à couvrir le nom de domaine, l'hébergement et
|
||||||
|
le temps de développement. En remerciement, les contributeurs débloquent les bonus : la météo
|
||||||
|
historique avec comparaison aux normales climatiques, l'aperçu saisonnier des mois à venir,
|
||||||
|
ainsi que les nouvelles fonctions à venir.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Les contributions passent par un simple virement bancaire : aucun prestataire de paiement,
|
||||||
|
aucune donnée de carte conservée. Le détail figure dans les
|
||||||
|
<a href={href('/legal/terms')}>conditions contributeur</a> et dans la
|
||||||
|
<a href={href('/legal/privacy')}>politique de confidentialité</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions, retours ou signalements de bugs :
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Informazioni su Drizz.li" subtitle="Un piccolo sito meteo open source.">
|
||||||
|
<p>
|
||||||
|
Drizz.li è un sito meteo indipendente. Offre previsioni multi-modello, confronti tra modelli,
|
||||||
|
tendenze a 14 giorni, mappe meteo e dati storici, senza account, senza pubblicità e senza
|
||||||
|
tracciatori.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Come funziona</h2>
|
||||||
|
<p>
|
||||||
|
Tutti i dati provengono dalle ottime API aperte di
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, che aggregano i
|
||||||
|
servizi meteorologici nazionali (DWD, NOAA, Météo-France, MeteoSwiss e altri). Drizz.li è un
|
||||||
|
sito statico: le previsioni vengono scaricate direttamente dal tuo browser e disegnate sul tuo
|
||||||
|
dispositivo.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Il sito è open source. Chiunque può leggere il codice, segnalare problemi o contribuire con
|
||||||
|
miglioramenti.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Sostenere il progetto</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li è gratuito. Se lo trovi utile, puoi sostenerlo con un piccolo contributo mensile (da 3
|
||||||
|
€ al mese): aiuta a coprire dominio, hosting e tempo di sviluppo. Come ringraziamento, chi
|
||||||
|
sostiene il progetto sblocca gli extra: meteo storico con confronto rispetto alle normali
|
||||||
|
climatiche, le prospettive stagionali per i mesi a venire e le nuove funzioni man mano che
|
||||||
|
arrivano.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
I contributi avvengono con un semplice bonifico bancario: nessun gestore di pagamenti, nessun
|
||||||
|
dato della carta conservato. I dettagli sono nelle
|
||||||
|
<a href={href('/legal/terms')}>condizioni per i sostenitori</a> e nell'<a
|
||||||
|
href={href('/legal/privacy')}>informativa sulla privacy</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contatti</h2>
|
||||||
|
<p>
|
||||||
|
Domande, riscontri o segnalazioni di bug:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -132,4 +132,159 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Day switching ────────────────────────────────────────────────────────
|
||||||
|
Only the three regions whose content depends on the selected day take part
|
||||||
|
in the cross-fade - and nothing else is captured at all. A captured
|
||||||
|
element is neither painted nor hit-testable in the live page for the
|
||||||
|
length of the animation (per spec: as if it had visibility:hidden and
|
||||||
|
pointer-events:none), and the root is captured by default, which captures
|
||||||
|
the whole page. That is what made the strip unclickable and the page
|
||||||
|
unscrollable while a switch played - no amount of pointer-events on the
|
||||||
|
overlay could fix it, because the page underneath was gone too. With the
|
||||||
|
root left un-named the strip, the chrome and the scroller keep their live
|
||||||
|
pixels and stay fully interactive. */
|
||||||
|
:root.day-switch {
|
||||||
|
view-transition-name: none;
|
||||||
|
}
|
||||||
|
:root.day-switch .day-region-table {
|
||||||
|
view-transition-name: day-table;
|
||||||
|
}
|
||||||
|
:root.day-switch .day-region-summary {
|
||||||
|
view-transition-name: day-summary;
|
||||||
|
}
|
||||||
|
:root.day-switch .day-region-charts {
|
||||||
|
view-transition-name: day-charts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Region snapshots paint in a viewport-fixed layer above the live page, at
|
||||||
|
their full layout size - including the part of the table normally
|
||||||
|
scrolled up behind the sticky strip and the topbar. The strip used to be
|
||||||
|
captured purely to stay on top of that, but a captured strip is dead to
|
||||||
|
input; instead the whole overlay is clipped at the strip bar's bottom
|
||||||
|
edge (measured per switch by runDayTransition), so the snapshots stay
|
||||||
|
out of the chrome and the chrome stays live. */
|
||||||
|
:root.day-switch::view-transition {
|
||||||
|
clip-path: inset(var(--day-switch-clip, 0px) 0 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page swaps capture the topbar and sidebar: a region that is identical on
|
||||||
|
both sides should not be animated at all, so they are pinned here and
|
||||||
|
swapped outright below. */
|
||||||
|
:root.page-switch .topbar {
|
||||||
|
view-transition-name: topbar;
|
||||||
|
}
|
||||||
|
::view-transition-group(topbar) {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
:root.page-switch .sidebar-region {
|
||||||
|
view-transition-name: sidebar;
|
||||||
|
}
|
||||||
|
::view-transition-group(sidebar) {
|
||||||
|
z-index: 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.day-region-table,
|
||||||
|
.day-region-summary,
|
||||||
|
.day-region-charts,
|
||||||
|
.topbar,
|
||||||
|
.sidebar-region {
|
||||||
|
view-transition-name: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The snapshot layer is a fixed overlay across the whole viewport and it
|
||||||
|
hit-tests, so for the length of an animation every click would land on it
|
||||||
|
and die - a day tapped while the previous switch is still fading simply
|
||||||
|
went dead. Let input fall through to the live page instead: by the time
|
||||||
|
the pseudo elements exist the DOM is already in its final state, and a
|
||||||
|
click that starts a new transition supersedes the running one cleanly
|
||||||
|
(see view-transition.ts). */
|
||||||
|
::view-transition {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cross-fades that do not dip ──────────────────────────────────────────
|
||||||
|
The default cross-fade is NOT opacity-neutral: coverage of two stacked
|
||||||
|
layers is `new + old * (1 - new)`, so at the midpoint the pair covers only
|
||||||
|
~75% of the region and the page background shows through both. That dip is
|
||||||
|
the flash - over the whole viewport for a page swap, and over the table,
|
||||||
|
summary and charts (between them the whole content column) for a day
|
||||||
|
switch.
|
||||||
|
|
||||||
|
Two ways out, and which one applies depends on whether the *incoming*
|
||||||
|
snapshot is opaque. Holding the outgoing one at full opacity fixes the
|
||||||
|
coverage arithmetic, but a translucent incoming snapshot then reads
|
||||||
|
straight through it and the old content lingers as a ghost. */
|
||||||
|
|
||||||
|
/* The document background is opaque, so the whole-viewport swap can simply
|
||||||
|
hold the outgoing snapshot until the pseudo elements are torn down. */
|
||||||
|
@keyframes vt-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
::view-transition-old(root) {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: vt-fade-in 400ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The day regions are not opaque - they are cards with gaps and headings
|
||||||
|
between them - so they keep a real cross-fade and fix the dip the other
|
||||||
|
way: `plus-lighter` makes the two halves sum to exactly the original
|
||||||
|
wherever they agree, which is most of the region (the card backgrounds are
|
||||||
|
the same on both days; only the readings differ). */
|
||||||
|
::view-transition-old(day-table),
|
||||||
|
::view-transition-new(day-table),
|
||||||
|
::view-transition-old(day-summary),
|
||||||
|
::view-transition-new(day-summary),
|
||||||
|
::view-transition-old(day-charts),
|
||||||
|
::view-transition-new(day-charts) {
|
||||||
|
animation-duration: 420ms;
|
||||||
|
animation-timing-function: ease;
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pinned chrome swaps outright: identical on both sides, so any animation
|
||||||
|
would only risk a flicker. */
|
||||||
|
::view-transition-old(topbar),
|
||||||
|
::view-transition-old(sidebar) {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
::view-transition-new(topbar),
|
||||||
|
::view-transition-new(sidebar) {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for browsers without view transitions: fade the colours only. */
|
||||||
|
:root.theme-transition,
|
||||||
|
:root.theme-transition *,
|
||||||
|
:root.theme-transition *::before,
|
||||||
|
:root.theme-transition *::after {
|
||||||
|
transition:
|
||||||
|
background-color 400ms ease,
|
||||||
|
border-color 400ms ease,
|
||||||
|
color 400ms ease,
|
||||||
|
fill 400ms ease,
|
||||||
|
stroke 400ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
:root.theme-transition,
|
||||||
|
:root.theme-transition *,
|
||||||
|
:root.theme-transition *::before,
|
||||||
|
:root.theme-transition *::after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LocalizedContent from '$lib/components/localized-content.svelte';
|
||||||
|
|
||||||
|
import de from './content/de.svelte';
|
||||||
|
import en from './content/en.svelte';
|
||||||
|
import es from './content/es.svelte';
|
||||||
|
import fr from './content/fr.svelte';
|
||||||
|
import it from './content/it.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LocalizedContent variants={{ en, de, es, fr, it }} />
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Impressum" subtitle="Anbieterkennzeichnung.">
|
||||||
|
<h2>Diensteanbieter</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2<br />
|
||||||
|
CH-6442 Gersau<br />
|
||||||
|
Switzerland
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
E-Mail: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
<p>Drizz.li wird als persönliches, unabhängiges Projekt betrieben.</p>
|
||||||
|
|
||||||
|
<h2>Haftungsausschluss</h2>
|
||||||
|
<p>
|
||||||
|
Die hier gezeigten Wetterdaten dienen ausschliesslich der allgemeinen Information. Vorhersagen
|
||||||
|
sind naturgemäss unsicher; verlassen Sie sich nicht auf sie, wenn Leben, Gesundheit oder
|
||||||
|
Sachwerte auf dem Spiel stehen - massgeblich sind die amtlichen Warnungen Ihres nationalen
|
||||||
|
Wetterdienstes.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Wetter- und Geodaten stammen von
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, das nicht mit
|
||||||
|
dieser Seite verbunden ist. Externe Links werden nach bestem Wissen gesetzt; für deren Inhalte
|
||||||
|
sind die jeweiligen Betreiber verantwortlich.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">Massgeblich ist die englische Fassung dieses Impressums.</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Imprint" subtitle="Legal notice / provider identification.">
|
||||||
|
<h2>Service provider</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2<br />
|
||||||
|
CH-6442 Gersau<br />
|
||||||
|
Switzerland
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
Email: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
<p>Drizz.li is operated as a personal, independent project.</p>
|
||||||
|
|
||||||
|
<h2>Disclaimer</h2>
|
||||||
|
<p>
|
||||||
|
Weather data shown on this site is provided for general information only. Forecasts are
|
||||||
|
inherently uncertain; do not rely on them where life, health or property is at stake - consult
|
||||||
|
official warnings from your national weather service instead.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Weather and geodata are retrieved from
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, which is not
|
||||||
|
affiliated with this site. External links are provided in good faith; their content is the
|
||||||
|
responsibility of the respective operators.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Aviso legal" subtitle="Identificación del prestador del servicio.">
|
||||||
|
<h2>Prestador del servicio</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2<br />
|
||||||
|
CH-6442 Gersau<br />
|
||||||
|
Switzerland
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
Correo electrónico: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
<p>Drizz.li se gestiona como un proyecto personal e independiente.</p>
|
||||||
|
|
||||||
|
<h2>Descargo de responsabilidad</h2>
|
||||||
|
<p>
|
||||||
|
Los datos meteorológicos de este sitio son solo informativos. Los pronósticos son
|
||||||
|
intrínsecamente inciertos; no confíes en ellos cuando estén en juego la vida, la salud o los
|
||||||
|
bienes: consulta los avisos oficiales de tu servicio meteorológico nacional.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Los datos meteorológicos y geográficos proceden de
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, que no está
|
||||||
|
afiliado a este sitio. Los enlaces externos se ofrecen de buena fe; su contenido es
|
||||||
|
responsabilidad de sus respectivos operadores.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
En caso de discrepancia prevalece la versión inglesa de este aviso legal.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Mentions légales" subtitle="Identification de l'éditeur.">
|
||||||
|
<h2>Éditeur du service</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2<br />
|
||||||
|
CH-6442 Gersau<br />
|
||||||
|
Switzerland
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
E-mail: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
<p>Drizz.li est exploité comme un projet personnel et indépendant.</p>
|
||||||
|
|
||||||
|
<h2>Avertissement</h2>
|
||||||
|
<p>
|
||||||
|
Les données météo présentées ici sont fournies à titre d'information générale. Les prévisions
|
||||||
|
sont par nature incertaines ; ne vous y fiez pas lorsque des vies, la santé ou des biens sont en
|
||||||
|
jeu - consultez les alertes officielles de votre service météorologique national.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Les données météo et géographiques proviennent d'
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, qui n'est pas
|
||||||
|
affilié à ce site. Les liens externes sont fournis de bonne foi ; leur contenu relève de la
|
||||||
|
responsabilité de leurs exploitants.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
En cas de divergence, la version anglaise de ces mentions fait foi.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Note legali" subtitle="Identificazione del fornitore del servizio.">
|
||||||
|
<h2>Fornitore del servizio</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2<br />
|
||||||
|
CH-6442 Gersau<br />
|
||||||
|
Switzerland
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
E-mail: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</p>
|
||||||
|
<p>Drizz.li è gestito come progetto personale e indipendente.</p>
|
||||||
|
|
||||||
|
<h2>Avvertenza</h2>
|
||||||
|
<p>
|
||||||
|
I dati meteo mostrati su questo sito hanno finalità puramente informative. Le previsioni sono
|
||||||
|
per natura incerte: non farvi affidamento quando sono in gioco la vita, la salute o i beni -
|
||||||
|
consulta gli avvisi ufficiali del tuo servizio meteorologico nazionale.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
I dati meteo e geografici provengono da
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, che non è
|
||||||
|
affiliato a questo sito. I link esterni sono forniti in buona fede; i contenuti sono
|
||||||
|
responsabilità dei rispettivi gestori.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
In caso di discrepanza prevale la versione inglese di queste note legali.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LocalizedContent from '$lib/components/localized-content.svelte';
|
||||||
|
|
||||||
|
import de from './content/de.svelte';
|
||||||
|
import en from './content/en.svelte';
|
||||||
|
import es from './content/es.svelte';
|
||||||
|
import fr from './content/fr.svelte';
|
||||||
|
import it from './content/it.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LocalizedContent variants={{ en, de, es, fr, it }} />
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Datenschutzerklärung" subtitle="Zuletzt aktualisiert: 1. August 2026">
|
||||||
|
<p>
|
||||||
|
Drizz.li ist so gebaut, dass möglichst wenige Ihrer Daten nötig sind: keine Benutzerkonten,
|
||||||
|
keine Cookies, keine Werbung, keine Analyse- oder Tracking-Skripte. Diese Seite erklärt, welche
|
||||||
|
wenige Verarbeitung dennoch stattfindet - beim Besuch der Seite und wenn Sie das Projekt mit
|
||||||
|
einem Beitrag unterstützen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Verantwortlicher</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||||
|
E-Mail:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
Diese Erklärung gilt für die Websites drizz.li (die Wetter-App) und support.drizz.li (die
|
||||||
|
Unterstützer-Anmeldung).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Besuch der Website</h2>
|
||||||
|
<h3>Abruf der Wetterdaten</h3>
|
||||||
|
<p>
|
||||||
|
Drizz.li ist eine statische Seite: Wenn Sie eine Vorhersage öffnen, ruft Ihr Browser die
|
||||||
|
Wetterdaten direkt bei den Open-Data-Schnittstellen von
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Wie bei jeder
|
||||||
|
Webanfrage werden dabei Ihre IP-Adresse und der gewünschte Ort bzw. Suchbegriff an Open-Meteo
|
||||||
|
übertragen. Wir erhalten und speichern davon nichts. Siehe
|
||||||
|
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||||
|
>Nutzungs- und Datenschutzhinweise von Open-Meteo</a
|
||||||
|
>. Rechtsgrundlage: unser berechtigtes Interesse an der Auslieferung der angeforderten Inhalte
|
||||||
|
(Art. 6 Abs. 1 lit. f DSGVO).
|
||||||
|
</p>
|
||||||
|
<h3>Hosting</h3>
|
||||||
|
<p>
|
||||||
|
Die statische Seite wird von [Hosting-Anbieter, Standort] ausgeliefert. Die
|
||||||
|
Hosting-Infrastruktur kann kurzlebige technische Server-Logs (IP-Adresse, angefragte URL,
|
||||||
|
Zeitstempel) zu Sicherheits- und Betriebszwecken speichern. Rechtsgrundlage: berechtigtes
|
||||||
|
Interesse an einem sicheren, zuverlässigen Dienst (Art. 6 Abs. 1 lit. f DSGVO).
|
||||||
|
</p>
|
||||||
|
<h3>Einstellungen auf Ihrem Gerät (Local Storage)</h3>
|
||||||
|
<p>
|
||||||
|
Ihre Einstellungen - Design, Masseinheiten, zuletzt gesuchter Ort und (für Unterstützer) Ihr
|
||||||
|
Zugangsschlüssel samt letztem Prüfergebnis - werden ausschliesslich im Local Storage Ihres
|
||||||
|
Browsers gespeichert. Sie verlassen Ihr Gerät nicht, ausser wie unten für die Schlüsselprüfung
|
||||||
|
beschrieben, und Sie können sie jederzeit über Ihren Browser löschen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Das Projekt unterstützen (Beiträge)</h2>
|
||||||
|
<p>
|
||||||
|
Unterstützerbeiträge schalten die Extras frei, rechtlich sind sie daher eine entgeltliche
|
||||||
|
Vereinbarung und keine reine Schenkung - das verarbeiten wir zur Abwicklung:
|
||||||
|
</p>
|
||||||
|
<h3>Anmeldeformular</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>E-Mail-Adresse</strong> (erforderlich) - um Ihnen die Überweisungsdaten und nach Eingang
|
||||||
|
Ihres Beitrags den Zugangsschlüssel zu senden.
|
||||||
|
</li>
|
||||||
|
<li><strong>Name</strong> (optional) - zur leichteren Zuordnung Ihrer Überweisung.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Währung und Betrag</strong> - anhand der Spracheinstellung/Zeitzone Ihres Browsers auf Ihrem
|
||||||
|
Gerät ermittelt (keine Standortabfrage).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>IP-Adresse und technische Anti-Bot-Signale</strong> - zusammen mit der Anfrage gespeichert,
|
||||||
|
um Missbrauch und Spam zu verhindern.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Zahlungsreferenz</strong> (z. B. DRZ-XXXXXX) - je Anfrage erzeugt, um Ihre Überweisung Ihrer
|
||||||
|
Anmeldung zuzuordnen.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Die Überweisungsdaten (Bankverbindung und Referenz) senden wir Ihnen per E-Mail statt sie auf
|
||||||
|
der Seite anzuzeigen. Rechtsgrundlagen: Erfüllung der Vereinbarung (Art. 6 Abs. 1 lit. b DSGVO)
|
||||||
|
und für die Missbrauchsabwehr berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO).
|
||||||
|
</p>
|
||||||
|
<h3>Banküberweisung</h3>
|
||||||
|
<p>
|
||||||
|
Beiträge werden per gewöhnlicher Banküberweisung gezahlt. Ihre und unsere Bank verarbeiten die
|
||||||
|
Überweisungsdaten in eigener Verantwortung; auf unserem Kontoauszug sehen wir die üblichen
|
||||||
|
Angaben (Name, Kontonummer, Betrag, Referenz). Wir nutzen sie nur zur Zuordnung Ihres Beitrags
|
||||||
|
und sind nach handels- und steuerrechtlichen Vorschriften zur Aufbewahrung verpflichtet (Art. 6
|
||||||
|
Abs. 1 lit. c DSGVO).
|
||||||
|
</p>
|
||||||
|
<h3>Zugangsschlüssel und Prüfung</h3>
|
||||||
|
<p>
|
||||||
|
Nach Zuordnung Ihrer Überweisung (manuell, meist innerhalb von 24 Stunden) erhalten Sie den
|
||||||
|
Zugangsschlüssel per E-Mail. Wenn Sie ihn in Drizz.li einfügen, sendet Ihr Browser den Schlüssel
|
||||||
|
an unseren Prüfendpunkt (support.drizz.li), um seine Gültigkeit zu prüfen; die Antwort enthält
|
||||||
|
nur Gültigkeit, Stufe und Ablauf. Die Abonnentenliste (Schlüssel, E-Mail, Ablauf) liegt auf
|
||||||
|
unserem Server, solange Ihr Zugang aktiv ist.
|
||||||
|
</p>
|
||||||
|
<h3>E-Mail</h3>
|
||||||
|
<p>
|
||||||
|
Transaktions-E-Mails (Überweisungsdaten, Zugangsschlüssel, Verlängerungen) versenden wir über
|
||||||
|
unseren E-Mail-Anbieter Strato (Strato AG, Deutschland) als Auftragsverarbeiter. Newsletter oder
|
||||||
|
Werbe-E-Mails versenden wir nicht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Speicherdauer</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Nicht bezahlte Anmeldungen werden spätestens nach 6 Monaten gelöscht.</li>
|
||||||
|
<li>
|
||||||
|
Unterstützerdaten (Schlüssel, E-Mail, Ablauf) werden für die Dauer Ihres Zugangs aufbewahrt
|
||||||
|
und innerhalb von 12 Monaten nach dessen Ablauf gelöscht, sofern Sie keine frühere Löschung
|
||||||
|
wünschen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Buchhaltungsunterlagen (Kontoauszüge mit Ihrer Überweisung) werden für die gesetzliche
|
||||||
|
Aufbewahrungsfrist gespeichert (je nach Rechtsordnung bis zu 10 Jahre).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Empfänger</h2>
|
||||||
|
<p>
|
||||||
|
Wir verkaufen personenbezogene Daten nicht und geben sie nicht für eigene Zwecke Dritter weiter.
|
||||||
|
Empfänger sind ausschliesslich: Open-Meteo (Wetterabrufe direkt durch Ihren Browser), unser
|
||||||
|
Hosting-Anbieter, unser E-Mail-Anbieter (Strato) und die an der Überweisung beteiligten Banken.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Ihre Rechte</h2>
|
||||||
|
<p>
|
||||||
|
Nach der DSGVO haben Sie das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der
|
||||||
|
Verarbeitung, Datenübertragbarkeit sowie das Recht, einer auf berechtigtem Interesse beruhenden
|
||||||
|
Verarbeitung zu widersprechen. Beruht eine Verarbeitung auf Einwilligung, können Sie diese
|
||||||
|
jederzeit widerrufen. Zur Ausübung schreiben Sie an
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. Ausserdem haben
|
||||||
|
Sie das Recht, sich bei einer Aufsichtsbehörde zu beschweren, insbesondere im EU-Mitgliedstaat
|
||||||
|
Ihres Wohnsitzes, oder bei [zuständige Aufsichtsbehörde des Verantwortlichen].
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Für Besucherinnen und Besucher aus der Schweiz gelten die entsprechenden Rechte nach dem
|
||||||
|
Bundesgesetz über den Datenschutz (DSG) sinngemäss.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Änderungen</h2>
|
||||||
|
<p>
|
||||||
|
Wir aktualisieren diese Erklärung, wenn sich der Dienst ändert; das Datum oben nennt die letzte
|
||||||
|
Fassung.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Massgeblich ist die englische Fassung dieser Datenschutzerklärung.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Privacy policy" subtitle="Last updated: 1 August 2026">
|
||||||
|
<p>
|
||||||
|
Drizz.li is built to need as little of your data as possible: there are no user accounts, no
|
||||||
|
cookies, no advertising and no analytics or tracking scripts. This page explains what little
|
||||||
|
processing does happen - when you browse the site, and when you support the project with a
|
||||||
|
contribution.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Controller</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||||
|
Email:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
This policy covers the websites drizz.li (the weather app) and support.drizz.li (the supporter
|
||||||
|
signup page).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Browsing the site</h2>
|
||||||
|
<h3>Weather data requests</h3>
|
||||||
|
<p>
|
||||||
|
Drizz.li is a static site: when you open a forecast, your browser fetches the weather data
|
||||||
|
directly from the open-data APIs of
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>
|
||||||
|
(api.open-meteo.com, geocoding-api.open-meteo.com, archive-api.open-meteo.com, ensemble-api.open-meteo.com,
|
||||||
|
and map tiles from maps.open-meteo.com / map-tiles.open-meteo.com / map-assets.open-meteo.com). Like
|
||||||
|
any web request, this transmits your IP address and the requested location or search term to Open-Meteo.
|
||||||
|
We do not receive or store any of this. See
|
||||||
|
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||||
|
>Open-Meteo's terms and privacy information</a
|
||||||
|
>. Legal basis: our legitimate interest in delivering the content you request (Art. 6(1)(f)
|
||||||
|
GDPR).
|
||||||
|
</p>
|
||||||
|
<h3>Hosting</h3>
|
||||||
|
<p>
|
||||||
|
The static site is served by [hosting provider, location]. The hosting infrastructure may keep
|
||||||
|
short-lived technical server logs (IP address, requested URL, timestamp) for security and
|
||||||
|
operations. Legal basis: legitimate interest in a secure, reliable service (Art. 6(1)(f) GDPR).
|
||||||
|
</p>
|
||||||
|
<h3>Settings on your device (local storage)</h3>
|
||||||
|
<p>
|
||||||
|
Your preferences - theme, measurement units, last searched location, and (for supporters) your
|
||||||
|
access key and its last verification result - are stored only in your browser's local storage.
|
||||||
|
They never leave your device except as described below for key verification, and you can clear
|
||||||
|
them at any time via your browser.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Supporting the project (contributions)</h2>
|
||||||
|
<p>
|
||||||
|
Supporter contributions unlock the supporter extras, so legally they are a paid agreement, not a
|
||||||
|
pure gift - and this is what we process to handle them:
|
||||||
|
</p>
|
||||||
|
<h3>Signup form</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Email address</strong> (required) - to send you the transfer details and, after your contribution
|
||||||
|
arrives, your access key.
|
||||||
|
</li>
|
||||||
|
<li><strong>Name</strong> (optional) - to help match your bank transfer.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Currency and amount</strong> - shown based on your browser's locale/timezone, detected on
|
||||||
|
your device (no geolocation request is made).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>IP address and technical anti-bot signals</strong> - kept with the signup request to prevent
|
||||||
|
abuse and spam of the form.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Payment reference</strong> (e.g. DRZ-XXXXXX) - generated per request to match your transfer
|
||||||
|
to your signup.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
The transfer details (bank account and reference) are sent to you by email rather than shown on
|
||||||
|
the page. Legal bases: performance of the agreement (Art. 6(1)(b) GDPR) and, for the anti-abuse
|
||||||
|
measures, legitimate interest (Art. 6(1)(f) GDPR).
|
||||||
|
</p>
|
||||||
|
<h3>Bank transfer</h3>
|
||||||
|
<p>
|
||||||
|
Contributions are paid by ordinary bank transfer. Your bank and ours process the transfer data
|
||||||
|
under their own responsibility; on our bank statement we see the usual transfer details (your
|
||||||
|
name, account number, amount, reference). We use them only to match your contribution and are
|
||||||
|
required to retain accounting records under statutory bookkeeping and tax law (Art. 6(1)(c)
|
||||||
|
GDPR).
|
||||||
|
</p>
|
||||||
|
<h3>Access key and verification</h3>
|
||||||
|
<p>
|
||||||
|
After your transfer is matched (manually, usually within 24 hours), you receive an access key by
|
||||||
|
email. When you paste it into Drizz.li, your browser sends the key to our verification endpoint
|
||||||
|
(support.drizz.li) to check whether it is active; the response contains only the validity, tier
|
||||||
|
and expiry. The subscriber list (key, email, expiry) is stored on our server for as long as your
|
||||||
|
access is active.
|
||||||
|
</p>
|
||||||
|
<h3>Email</h3>
|
||||||
|
<p>
|
||||||
|
Transactional emails (transfer details, access key, renewals) are sent through our email
|
||||||
|
provider, Strato (Strato AG, Germany), acting as a processor. We send no newsletters or
|
||||||
|
marketing email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Retention</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Signup requests that are never paid are deleted after 6 months at the latest.</li>
|
||||||
|
<li>
|
||||||
|
Supporter records (key, email, expiry) are kept while your access is active and deleted within
|
||||||
|
12 months after it expires, unless you ask us to delete them sooner.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Accounting records (bank statements showing your transfer) are kept for the statutory
|
||||||
|
retention period (up to 10 years, depending on jurisdiction).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Recipients</h2>
|
||||||
|
<p>
|
||||||
|
We do not sell or share personal data with third parties for their own purposes. Recipients are
|
||||||
|
limited to: Open-Meteo (weather requests made directly by your browser), our hosting provider,
|
||||||
|
our email provider (Strato), and the banks involved in your transfer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Your rights</h2>
|
||||||
|
<p>
|
||||||
|
Under the GDPR you have the right to access, rectification, erasure, restriction of processing,
|
||||||
|
data portability, and to object to processing based on legitimate interest. Where processing is
|
||||||
|
based on consent, you may withdraw it at any time. To exercise any of these rights, email
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. You also have the
|
||||||
|
right to lodge a complaint with a supervisory authority, in particular in the EU member state of
|
||||||
|
your residence, or with [competent supervisory authority of the controller].
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For visitors from Switzerland: the corresponding rights under the Swiss Federal Act on Data
|
||||||
|
Protection (FADP) apply equivalently.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Changes</h2>
|
||||||
|
<p>
|
||||||
|
We may update this policy when the service changes; the date above reflects the latest revision.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Política de privacidad" subtitle="Última actualización: 1 de agosto de 2026">
|
||||||
|
<p>
|
||||||
|
Drizz.li está hecho para necesitar los mínimos datos posibles: sin cuentas, sin cookies, sin
|
||||||
|
publicidad y sin scripts de analítica ni de seguimiento. Esta página explica el poco tratamiento
|
||||||
|
que sí ocurre: al navegar por el sitio y al apoyar el proyecto con una aportación.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Responsable</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||||
|
Correo electrónico:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
Esta política cubre los sitios drizz.li (la app meteorológica) y support.drizz.li (la página de
|
||||||
|
registro de colaboradores).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Navegación por el sitio</h2>
|
||||||
|
<h3>Peticiones de datos meteorológicos</h3>
|
||||||
|
<p>
|
||||||
|
Drizz.li es un sitio estático: cuando abres un pronóstico, tu navegador obtiene los datos
|
||||||
|
directamente de las API abiertas de
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Como en
|
||||||
|
cualquier petición web, esto transmite tu dirección IP y la ubicación o término buscado a
|
||||||
|
Open-Meteo. Nosotros no recibimos ni almacenamos nada de eso. Consulta
|
||||||
|
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||||
|
>las condiciones e información de privacidad de Open-Meteo</a
|
||||||
|
>. Base jurídica: nuestro interés legítimo en entregar el contenido que solicitas (art. 6.1.f
|
||||||
|
del RGPD).
|
||||||
|
</p>
|
||||||
|
<h3>Alojamiento</h3>
|
||||||
|
<p>
|
||||||
|
El sitio estático lo sirve [proveedor de alojamiento, ubicación]. La infraestructura puede
|
||||||
|
conservar registros técnicos de corta duración (dirección IP, URL solicitada, marca de tiempo)
|
||||||
|
por seguridad y operación. Base jurídica: interés legítimo en un servicio seguro y fiable (art.
|
||||||
|
6.1.f del RGPD).
|
||||||
|
</p>
|
||||||
|
<h3>Ajustes en tu dispositivo (almacenamiento local)</h3>
|
||||||
|
<p>
|
||||||
|
Tus preferencias - tema, unidades, última ubicación buscada y (para colaboradores) tu clave de
|
||||||
|
acceso y su última verificación - se guardan únicamente en el almacenamiento local de tu
|
||||||
|
navegador. No salen de tu dispositivo salvo como se describe abajo para verificar la clave, y
|
||||||
|
puedes borrarlas cuando quieras.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Apoyar el proyecto (aportaciones)</h2>
|
||||||
|
<p>
|
||||||
|
Las aportaciones desbloquean los extras, así que legalmente son un acuerdo remunerado y no una
|
||||||
|
donación pura; esto es lo que tratamos para gestionarlas:
|
||||||
|
</p>
|
||||||
|
<h3>Formulario de registro</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Dirección de correo</strong> (obligatoria) - para enviarte los datos de la transferencia
|
||||||
|
y, cuando llegue tu aportación, tu clave de acceso.
|
||||||
|
</li>
|
||||||
|
<li><strong>Nombre</strong> (opcional) - para ayudar a casar tu transferencia.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Moneda e importe</strong> - determinados por la configuración regional/zona horaria de tu
|
||||||
|
navegador, en tu dispositivo (no se solicita geolocalización).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Dirección IP y señales técnicas anti-bots</strong> - guardadas con la solicitud para evitar
|
||||||
|
abusos y spam.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Referencia de pago</strong> (p. ej. DRZ-XXXXXX) - generada por solicitud para casar tu transferencia
|
||||||
|
con tu registro.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Los datos de la transferencia (cuenta y referencia) se te envían por correo en lugar de
|
||||||
|
mostrarse en la página. Bases jurídicas: ejecución del contrato (art. 6.1.b del RGPD) y, para
|
||||||
|
las medidas antiabuso, interés legítimo (art. 6.1.f del RGPD).
|
||||||
|
</p>
|
||||||
|
<h3>Transferencia bancaria</h3>
|
||||||
|
<p>
|
||||||
|
Las aportaciones se pagan por transferencia bancaria ordinaria. Tu banco y el nuestro tratan los
|
||||||
|
datos bajo su propia responsabilidad; en nuestro extracto vemos los datos habituales (nombre,
|
||||||
|
número de cuenta, importe, referencia). Solo los usamos para casar tu aportación y debemos
|
||||||
|
conservar los registros contables por obligación legal (art. 6.1.c del RGPD).
|
||||||
|
</p>
|
||||||
|
<h3>Clave de acceso y verificación</h3>
|
||||||
|
<p>
|
||||||
|
Una vez casada tu transferencia (manualmente, normalmente en 24 horas), recibes una clave de
|
||||||
|
acceso por correo. Al pegarla en Drizz.li, tu navegador envía la clave a nuestro punto de
|
||||||
|
verificación (support.drizz.li) para comprobar si está activa; la respuesta contiene solo
|
||||||
|
validez, nivel y caducidad. La lista de suscriptores (clave, correo, caducidad) se guarda en
|
||||||
|
nuestro servidor mientras tu acceso esté activo.
|
||||||
|
</p>
|
||||||
|
<h3>Correo electrónico</h3>
|
||||||
|
<p>
|
||||||
|
Los correos transaccionales (datos de transferencia, clave de acceso, renovaciones) se envían a
|
||||||
|
través de nuestro proveedor Strato (Strato AG, Alemania), que actúa como encargado del
|
||||||
|
tratamiento. No enviamos boletines ni correo comercial.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Conservación</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Las solicitudes de registro que nunca se pagan se eliminan como máximo a los 6 meses.</li>
|
||||||
|
<li>
|
||||||
|
Los registros de colaborador (clave, correo, caducidad) se conservan mientras tu acceso esté
|
||||||
|
activo y se eliminan en los 12 meses siguientes a su expiración, salvo que pidas borrarlos
|
||||||
|
antes.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Los registros contables (extractos que muestran tu transferencia) se conservan durante el
|
||||||
|
plazo legal (hasta 10 años según la jurisdicción).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Destinatarios</h2>
|
||||||
|
<p>
|
||||||
|
No vendemos ni compartimos datos personales con terceros para sus propios fines. Los
|
||||||
|
destinatarios se limitan a: Open-Meteo (peticiones meteorológicas hechas directamente por tu
|
||||||
|
navegador), nuestro proveedor de alojamiento, nuestro proveedor de correo (Strato) y los bancos
|
||||||
|
implicados en tu transferencia.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Tus derechos</h2>
|
||||||
|
<p>
|
||||||
|
Conforme al RGPD tienes derecho de acceso, rectificación, supresión, limitación del tratamiento,
|
||||||
|
portabilidad y oposición al tratamiento basado en el interés legítimo. Cuando el tratamiento se
|
||||||
|
base en el consentimiento, puedes retirarlo en cualquier momento. Para ejercer estos derechos,
|
||||||
|
escribe a
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. También tienes
|
||||||
|
derecho a presentar una reclamación ante una autoridad de control, en particular en el Estado
|
||||||
|
miembro de tu residencia, o ante [autoridad de control competente].
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Para visitantes desde Suiza, se aplican de forma equivalente los derechos correspondientes de la
|
||||||
|
Ley Federal de Protección de Datos (LPD).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Cambios</h2>
|
||||||
|
<p>
|
||||||
|
Podemos actualizar esta política cuando cambie el servicio; la fecha de arriba refleja la última
|
||||||
|
revisión.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
En caso de discrepancia prevalece la versión inglesa de esta política.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Politique de confidentialité" subtitle="Dernière mise à jour : 1er août 2026">
|
||||||
|
<p>
|
||||||
|
Drizz.li est conçu pour avoir besoin du minimum de vos données : aucun compte, aucun cookie,
|
||||||
|
aucune publicité, aucun script d'analyse ou de suivi. Cette page explique le peu de traitement
|
||||||
|
qui a lieu - lorsque vous consultez le site et lorsque vous soutenez le projet par une
|
||||||
|
contribution.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Responsable du traitement</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||||
|
E-mail:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
Cette politique couvre les sites drizz.li (l'application météo) et support.drizz.li (la page
|
||||||
|
d'inscription contributeur).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Navigation sur le site</h2>
|
||||||
|
<h3>Requêtes de données météo</h3>
|
||||||
|
<p>
|
||||||
|
Drizz.li est un site statique : lorsque vous ouvrez une prévision, votre navigateur récupère les
|
||||||
|
données directement auprès des API ouvertes d'
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Comme pour toute
|
||||||
|
requête web, cela transmet votre adresse IP et le lieu ou le terme recherché à Open-Meteo. Nous
|
||||||
|
n'en recevons ni n'en conservons rien. Voir
|
||||||
|
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||||
|
>les conditions et informations de confidentialité d'Open-Meteo</a
|
||||||
|
>. Base légale : notre intérêt légitime à fournir le contenu demandé (art. 6, par. 1, point f)
|
||||||
|
du RGPD).
|
||||||
|
</p>
|
||||||
|
<h3>Hébergement</h3>
|
||||||
|
<p>
|
||||||
|
Le site statique est servi par [hébergeur, localisation]. L'infrastructure d'hébergement peut
|
||||||
|
conserver de brefs journaux techniques (adresse IP, URL demandée, horodatage) à des fins de
|
||||||
|
sécurité et d'exploitation. Base légale : intérêt légitime à un service sûr et fiable (art. 6,
|
||||||
|
par. 1, point f) du RGPD).
|
||||||
|
</p>
|
||||||
|
<h3>Réglages sur votre appareil (stockage local)</h3>
|
||||||
|
<p>
|
||||||
|
Vos préférences - thème, unités, dernier lieu recherché et (pour les contributeurs) votre clé
|
||||||
|
d'accès et son dernier résultat de vérification - sont stockées uniquement dans le stockage
|
||||||
|
local de votre navigateur. Elles ne quittent pas votre appareil, sauf comme décrit ci-dessous
|
||||||
|
pour la vérification de la clé, et vous pouvez les effacer à tout moment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Soutenir le projet (contributions)</h2>
|
||||||
|
<p>
|
||||||
|
Les contributions débloquent les bonus : juridiquement il s'agit donc d'un accord payant et non
|
||||||
|
d'un simple don - voici ce que nous traitons pour les gérer :
|
||||||
|
</p>
|
||||||
|
<h3>Formulaire d'inscription</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Adresse e-mail</strong> (obligatoire) - pour vous envoyer les coordonnées bancaires puis,
|
||||||
|
à réception de votre contribution, votre clé d'accès.
|
||||||
|
</li>
|
||||||
|
<li><strong>Nom</strong> (facultatif) - pour faciliter le rapprochement de votre virement.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Devise et montant</strong> - déterminés d'après la langue/le fuseau horaire de votre navigateur,
|
||||||
|
sur votre appareil (aucune demande de géolocalisation).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Adresse IP et signaux anti-robots techniques</strong> - conservés avec la demande pour prévenir
|
||||||
|
les abus et le spam.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Référence de paiement</strong> (p. ex. DRZ-XXXXXX) - générée par demande pour rapprocher
|
||||||
|
votre virement de votre inscription.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Les coordonnées bancaires (compte et référence) vous sont envoyées par e-mail plutôt
|
||||||
|
qu'affichées sur la page. Bases légales : exécution du contrat (art. 6, par. 1, point b) du
|
||||||
|
RGPD) et, pour les mesures anti-abus, intérêt légitime (art. 6, par. 1, point f) du RGPD).
|
||||||
|
</p>
|
||||||
|
<h3>Virement bancaire</h3>
|
||||||
|
<p>
|
||||||
|
Les contributions sont réglées par virement bancaire ordinaire. Votre banque et la nôtre
|
||||||
|
traitent les données du virement sous leur propre responsabilité ; sur notre relevé figurent les
|
||||||
|
informations habituelles (nom, numéro de compte, montant, référence). Nous ne les utilisons que
|
||||||
|
pour rapprocher votre contribution et devons conserver les pièces comptables au titre des
|
||||||
|
obligations légales (art. 6, par. 1, point c) du RGPD).
|
||||||
|
</p>
|
||||||
|
<h3>Clé d'accès et vérification</h3>
|
||||||
|
<p>
|
||||||
|
Une fois votre virement rapproché (manuellement, en général sous 24 heures), vous recevez une
|
||||||
|
clé d'accès par e-mail. Lorsque vous la collez dans Drizz.li, votre navigateur envoie la clé à
|
||||||
|
notre point de vérification (support.drizz.li) pour savoir si elle est active ; la réponse ne
|
||||||
|
contient que la validité, le niveau et l'échéance. La liste des abonnés (clé, e-mail, échéance)
|
||||||
|
est conservée sur notre serveur tant que votre accès est actif.
|
||||||
|
</p>
|
||||||
|
<h3>E-mail</h3>
|
||||||
|
<p>
|
||||||
|
Les e-mails transactionnels (coordonnées bancaires, clé d'accès, renouvellements) sont envoyés
|
||||||
|
via notre prestataire Strato (Strato AG, Allemagne), agissant comme sous-traitant. Nous
|
||||||
|
n'envoyons ni newsletter ni e-mail marketing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Conservation</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Les demandes d'inscription jamais payées sont supprimées au plus tard après 6 mois.</li>
|
||||||
|
<li>
|
||||||
|
Les données contributeur (clé, e-mail, échéance) sont conservées tant que votre accès est
|
||||||
|
actif et supprimées dans les 12 mois suivant son expiration, sauf demande de suppression
|
||||||
|
anticipée.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Les pièces comptables (relevés bancaires mentionnant votre virement) sont conservées pendant
|
||||||
|
la durée légale (jusqu'à 10 ans selon la juridiction).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Destinataires</h2>
|
||||||
|
<p>
|
||||||
|
Nous ne vendons ni ne partageons de données personnelles avec des tiers pour leurs propres
|
||||||
|
finalités. Les destinataires se limitent à : Open-Meteo (requêtes météo effectuées directement
|
||||||
|
par votre navigateur), notre hébergeur, notre prestataire e-mail (Strato) et les banques
|
||||||
|
impliquées dans votre virement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Vos droits</h2>
|
||||||
|
<p>
|
||||||
|
En vertu du RGPD, vous disposez d'un droit d'accès, de rectification, d'effacement, de
|
||||||
|
limitation du traitement, de portabilité, et d'opposition au traitement fondé sur l'intérêt
|
||||||
|
légitime. Lorsque le traitement repose sur le consentement, vous pouvez le retirer à tout
|
||||||
|
moment. Pour exercer ces droits, écrivez à
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. Vous avez
|
||||||
|
également le droit d'introduire une réclamation auprès d'une autorité de contrôle, notamment
|
||||||
|
dans l'État membre de votre résidence, ou auprès de [autorité de contrôle compétente].
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Pour les visiteurs suisses, les droits correspondants de la loi fédérale sur la protection des
|
||||||
|
données (LPD) s'appliquent de manière équivalente.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Modifications</h2>
|
||||||
|
<p>
|
||||||
|
Nous pouvons mettre à jour cette politique lorsque le service évolue ; la date ci-dessus indique
|
||||||
|
la dernière révision.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
En cas de divergence, la version anglaise de cette politique fait foi.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Informativa sulla privacy" subtitle="Ultimo aggiornamento: 1 agosto 2026">
|
||||||
|
<p>
|
||||||
|
Drizz.li è costruito per aver bisogno del minimo dei tuoi dati: nessun account, nessun cookie,
|
||||||
|
nessuna pubblicità, nessuno script di analisi o tracciamento. Questa pagina spiega il poco
|
||||||
|
trattamento che avviene comunque: quando navighi sul sito e quando sostieni il progetto con un
|
||||||
|
contributo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Titolare del trattamento</h2>
|
||||||
|
<address>
|
||||||
|
Vincent van der Wal<br />
|
||||||
|
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||||
|
E-mail:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
</address>
|
||||||
|
<p>
|
||||||
|
Questa informativa copre i siti drizz.li (l'app meteo) e support.drizz.li (la pagina di
|
||||||
|
iscrizione per i sostenitori).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Navigazione sul sito</h2>
|
||||||
|
<h3>Richieste di dati meteo</h3>
|
||||||
|
<p>
|
||||||
|
Drizz.li è un sito statico: quando apri una previsione, il tuo browser scarica i dati
|
||||||
|
direttamente dalle API aperte di
|
||||||
|
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Come per
|
||||||
|
qualsiasi richiesta web, questo trasmette il tuo indirizzo IP e la località o il termine cercato
|
||||||
|
a Open-Meteo. Noi non riceviamo né conserviamo nulla di tutto ciò. Vedi
|
||||||
|
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||||
|
>le condizioni e le informazioni sulla privacy di Open-Meteo</a
|
||||||
|
>. Base giuridica: il nostro legittimo interesse a fornire il contenuto richiesto (art. 6, par.
|
||||||
|
1, lett. f del GDPR).
|
||||||
|
</p>
|
||||||
|
<h3>Hosting</h3>
|
||||||
|
<p>
|
||||||
|
Il sito statico è servito da [fornitore di hosting, sede]. L'infrastruttura può conservare brevi
|
||||||
|
log tecnici del server (indirizzo IP, URL richiesto, data e ora) per sicurezza e gestione. Base
|
||||||
|
giuridica: legittimo interesse a un servizio sicuro e affidabile (art. 6, par. 1, lett. f del
|
||||||
|
GDPR).
|
||||||
|
</p>
|
||||||
|
<h3>Impostazioni sul tuo dispositivo (archiviazione locale)</h3>
|
||||||
|
<p>
|
||||||
|
Le tue preferenze - tema, unità di misura, ultima località cercata e (per i sostenitori) la
|
||||||
|
chiave di accesso e l'ultimo esito della verifica - sono salvate solo nell'archiviazione locale
|
||||||
|
del browser. Non lasciano il tuo dispositivo, salvo quanto descritto sotto per la verifica della
|
||||||
|
chiave, e puoi cancellarle quando vuoi.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Sostenere il progetto (contributi)</h2>
|
||||||
|
<p>
|
||||||
|
I contributi sbloccano gli extra, quindi giuridicamente sono un accordo a pagamento e non una
|
||||||
|
pura donazione: ecco cosa trattiamo per gestirli:
|
||||||
|
</p>
|
||||||
|
<h3>Modulo di iscrizione</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Indirizzo e-mail</strong> (obbligatorio) - per inviarti i dati del bonifico e, all'arrivo
|
||||||
|
del contributo, la chiave di accesso.
|
||||||
|
</li>
|
||||||
|
<li><strong>Nome</strong> (facoltativo) - per facilitare l'abbinamento del bonifico.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Valuta e importo</strong> - determinati dalle impostazioni locali/fuso orario del browser,
|
||||||
|
sul tuo dispositivo (nessuna richiesta di geolocalizzazione).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Indirizzo IP e segnali tecnici anti-bot</strong> - conservati con la richiesta per prevenire
|
||||||
|
abusi e spam.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Riferimento di pagamento</strong> (es. DRZ-XXXXXX) - generato per ogni richiesta per abbinare
|
||||||
|
il bonifico all'iscrizione.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
I dati del bonifico (conto e riferimento) ti vengono inviati via e-mail anziché mostrati sulla
|
||||||
|
pagina. Basi giuridiche: esecuzione del contratto (art. 6, par. 1, lett. b del GDPR) e, per le
|
||||||
|
misure anti-abuso, legittimo interesse (art. 6, par. 1, lett. f del GDPR).
|
||||||
|
</p>
|
||||||
|
<h3>Bonifico bancario</h3>
|
||||||
|
<p>
|
||||||
|
I contributi si pagano con un normale bonifico. La tua banca e la nostra trattano i dati del
|
||||||
|
bonifico sotto la propria responsabilità; sul nostro estratto conto vediamo i dati consueti
|
||||||
|
(nome, numero di conto, importo, riferimento). Li usiamo solo per abbinare il contributo e siamo
|
||||||
|
tenuti a conservare le scritture contabili per obbligo di legge (art. 6, par. 1, lett. c del
|
||||||
|
GDPR).
|
||||||
|
</p>
|
||||||
|
<h3>Chiave di accesso e verifica</h3>
|
||||||
|
<p>
|
||||||
|
Dopo l'abbinamento del bonifico (manuale, di solito entro 24 ore) ricevi una chiave di accesso
|
||||||
|
via e-mail. Quando la incolli in Drizz.li, il browser invia la chiave al nostro endpoint di
|
||||||
|
verifica (support.drizz.li) per controllare se è attiva; la risposta contiene solo validità,
|
||||||
|
livello e scadenza. L'elenco dei sostenitori (chiave, e-mail, scadenza) resta sul nostro server
|
||||||
|
finché il tuo accesso è attivo.
|
||||||
|
</p>
|
||||||
|
<h3>E-mail</h3>
|
||||||
|
<p>
|
||||||
|
Le e-mail transazionali (dati del bonifico, chiave di accesso, rinnovi) sono inviate tramite il
|
||||||
|
nostro fornitore Strato (Strato AG, Germania), che agisce come responsabile del trattamento. Non
|
||||||
|
inviamo newsletter né e-mail di marketing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Conservazione</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Le richieste di iscrizione mai pagate sono cancellate al più tardi dopo 6 mesi.</li>
|
||||||
|
<li>
|
||||||
|
I dati dei sostenitori (chiave, e-mail, scadenza) sono conservati finché l'accesso è attivo e
|
||||||
|
cancellati entro 12 mesi dalla scadenza, salvo richiesta di cancellazione anticipata.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Le scritture contabili (estratti conto con il tuo bonifico) sono conservate per il periodo di
|
||||||
|
legge (fino a 10 anni a seconda della giurisdizione).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Destinatari</h2>
|
||||||
|
<p>
|
||||||
|
Non vendiamo né condividiamo dati personali con terzi per finalità proprie. I destinatari si
|
||||||
|
limitano a: Open-Meteo (richieste meteo fatte direttamente dal tuo browser), il nostro fornitore
|
||||||
|
di hosting, il nostro fornitore e-mail (Strato) e le banche coinvolte nel bonifico.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. I tuoi diritti</h2>
|
||||||
|
<p>
|
||||||
|
In base al GDPR hai diritto di accesso, rettifica, cancellazione, limitazione del trattamento,
|
||||||
|
portabilità dei dati e opposizione al trattamento fondato sul legittimo interesse. Se il
|
||||||
|
trattamento si basa sul consenso, puoi revocarlo in qualsiasi momento. Per esercitare questi
|
||||||
|
diritti scrivi a
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. Hai inoltre il
|
||||||
|
diritto di proporre reclamo a un'autorità di controllo, in particolare nello Stato membro di
|
||||||
|
residenza, o a [autorità di controllo competente].
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Per i visitatori dalla Svizzera valgono in modo equivalente i corrispondenti diritti previsti
|
||||||
|
dalla Legge federale sulla protezione dei dati (LPD).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Modifiche</h2>
|
||||||
|
<p>
|
||||||
|
Possiamo aggiornare questa informativa quando il servizio cambia; la data in alto indica
|
||||||
|
l'ultima revisione.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
In caso di discrepanza prevale la versione inglese di questa informativa.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LocalizedContent from '$lib/components/localized-content.svelte';
|
||||||
|
|
||||||
|
import de from './content/de.svelte';
|
||||||
|
import en from './content/en.svelte';
|
||||||
|
import es from './content/es.svelte';
|
||||||
|
import fr from './content/fr.svelte';
|
||||||
|
import it from './content/it.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LocalizedContent variants={{ en, de, es, fr, it }} />
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Unterstützerbedingungen" subtitle="Zuletzt aktualisiert: 1. August 2026">
|
||||||
|
<p>
|
||||||
|
Drizz.li ist kostenlos nutzbar. Diese Bedingungen betreffen den freiwilligen
|
||||||
|
Unterstützerbeitrag: eine kleine Zahlung, die das Projekt am Laufen hält und als Dankeschön die
|
||||||
|
Unterstützer-Extras freischaltet. Auch wenn wir von einem Beitrag sprechen, werden dafür
|
||||||
|
Funktionen freigeschaltet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Was Sie erhalten</h2>
|
||||||
|
<p>
|
||||||
|
Ein Unterstützerbeitrag schaltet die Extras für den bezahlten Zeitraum frei: derzeit
|
||||||
|
historisches Wetter mit Vergleich zu den Klimanormalen und die saisonalen Aussichten für die
|
||||||
|
kommenden Monate sowie neue Funktionen, sobald sie erscheinen. Die kostenlosen Teile von
|
||||||
|
Drizz.li bleiben für alle frei.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Ablauf</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Sie fordern die Überweisungsdaten auf der Anmeldeseite an; wir senden sie Ihnen zusammen mit
|
||||||
|
einer persönlichen Zahlungsreferenz per E-Mail.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Sie überweisen den Betrag (ab 3 € / 3 $ / 3 CHF pro Monat) per gewöhnlicher Banküberweisung
|
||||||
|
unter Angabe der Referenz.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Überweisungen werden manuell zugeordnet. Sobald Ihr Beitrag eingeht - meist innerhalb von 24
|
||||||
|
Stunden, höchstens einige Tage - erhalten Sie per E-Mail einen persönlichen Zugangsschlüssel.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Sie fügen den Schlüssel einmal in Drizz.li ein; er wird auf Ihrem Gerät gespeichert und
|
||||||
|
automatisch überprüft.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Keine automatische Verlängerung</h2>
|
||||||
|
<p>
|
||||||
|
Beiträge sind einmalig und im Voraus bezahlt: Nichts verlängert sich automatisch und wir
|
||||||
|
belasten Sie nie. Läuft Ihr Zeitraum ab, sperren sich die Extras einfach wieder; ein erneuter
|
||||||
|
Beitrag mit derselben E-Mail verlängert Ihren bestehenden Schlüssel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Widerruf und Erstattung</h2>
|
||||||
|
<p>
|
||||||
|
Als Verbraucherin oder Verbraucher in der EU/im EWR haben Sie ein gesetzliches 14-tägiges
|
||||||
|
Widerrufsrecht. Darüber hinaus halten wir es einfach: Sind Sie innerhalb von 14 Tagen nach
|
||||||
|
Erhalt Ihres Zugangsschlüssels aus irgendeinem Grund unzufrieden, schreiben Sie an
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
und wir erstatten Ihren Beitrag vollständig.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Faire Nutzung</h2>
|
||||||
|
<p>
|
||||||
|
Der Zugangsschlüssel ist persönlich. Bitte veröffentlichen oder teilen Sie ihn nicht; eindeutig
|
||||||
|
missbrauchte Schlüssel (z. B. öffentlich geteilt) können gesperrt werden. Wird Ihr Schlüssel
|
||||||
|
ohne Grund gesperrt, haben Sie Anspruch auf eine anteilige Erstattung.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Dienst und Verfügbarkeit</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li ist ein persönliches Open-Source-Projekt und wird ohne Gewähr bereitgestellt. Wir
|
||||||
|
bemühen uns um Verfügbarkeit und Genauigkeit, können aber weder unterbrechungsfreien Betrieb
|
||||||
|
noch die Richtigkeit der Vorhersagen garantieren - Wetterdaten sind rein informativ (siehe
|
||||||
|
Haftungsausschluss im Impressum). Sollten die Unterstützer-Extras während eines bezahlten
|
||||||
|
Zeitraums dauerhaft ausfallen, erstatten wir auf Anfrage den Restzeitraum.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Daten</h2>
|
||||||
|
<p>
|
||||||
|
Wie wir Ihre Daten (E-Mail, Zahlungsreferenz, Überweisungsdaten) verarbeiten, steht in der
|
||||||
|
<a href={href('/legal/privacy')}>Datenschutzerklärung</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Anwendbares Recht</h2>
|
||||||
|
<p>
|
||||||
|
Für diese Bedingungen gilt das Recht der [Schweiz], unbeschadet zwingender
|
||||||
|
Verbraucherschutzvorschriften Ihres Wohnsitzlandes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground">Massgeblich ist die englische Fassung dieser Bedingungen.</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Supporter terms" subtitle="Last updated: 1 August 2026">
|
||||||
|
<p>
|
||||||
|
Drizz.li is free to use. These terms cover the optional supporter contribution: a small payment
|
||||||
|
that helps keep the project running and, as a thank-you, unlocks the supporter extras. Although
|
||||||
|
we call it a contribution, features are unlocked in return.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. What you get</h2>
|
||||||
|
<p>
|
||||||
|
A supporter contribution unlocks the supporter extras for the paid period: currently historical
|
||||||
|
weather with climate-normal comparisons and the seasonal outlook for the months ahead, plus new
|
||||||
|
supporter features as they land. The free parts of Drizz.li stay free for everyone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. How it works</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
You request the transfer details on the signup page; we email them to you together with a
|
||||||
|
personal payment reference.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
You transfer the amount (from €3 / $3 / CHF 3 per month) by ordinary bank transfer, including
|
||||||
|
the reference.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Transfers are matched manually. Once your contribution arrives - usually within 24 hours, at
|
||||||
|
most a few days - you receive a personal access key by email.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
You paste the key into Drizz.li once; it is stored on your device and verified automatically.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. No auto-renewal</h2>
|
||||||
|
<p>
|
||||||
|
Contributions are one-off and prepaid: nothing renews automatically and we never charge you.
|
||||||
|
When your period ends, the extras simply lock again; contributing again with the same email
|
||||||
|
extends your existing key.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Withdrawal and refunds</h2>
|
||||||
|
<p>
|
||||||
|
If you are a consumer in the EU/EEA, you have a statutory 14-day right of withdrawal. Beyond
|
||||||
|
that, we keep it simple: if you are unhappy for any reason within 14 days of receiving your
|
||||||
|
access key, email
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a> and we will refund your
|
||||||
|
contribution in full.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Fair use</h2>
|
||||||
|
<p>
|
||||||
|
The access key is personal. Please don't publish or share it; keys that are clearly abused (e.g.
|
||||||
|
shared publicly) may be revoked. If your key is revoked without cause, you are entitled to a
|
||||||
|
pro-rata refund.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Service and availability</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li is a personal open-source project, provided as-is. We work to keep it available and
|
||||||
|
accurate, but we cannot guarantee uninterrupted availability or the correctness of forecasts -
|
||||||
|
weather data is informational only (see the disclaimer in the imprint). If the supporter extras
|
||||||
|
become permanently unavailable during a period you paid for, we will refund the remaining period
|
||||||
|
on request.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Data</h2>
|
||||||
|
<p>
|
||||||
|
How we handle your data (email, payment reference, bank transfer details) is described in the
|
||||||
|
<a href={href('/legal/privacy')}>privacy policy</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Governing law</h2>
|
||||||
|
<p>
|
||||||
|
These terms are governed by the law of [Switzerland], without prejudice to mandatory consumer
|
||||||
|
protection provisions of your country of residence.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage
|
||||||
|
title="Condiciones para colaboradores"
|
||||||
|
subtitle="Última actualización: 1 de agosto de 2026"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Drizz.li es gratuito. Estas condiciones cubren la aportación voluntaria de colaborador: un
|
||||||
|
pequeño pago que ayuda a mantener el proyecto y que, como agradecimiento, desbloquea los extras.
|
||||||
|
Aunque lo llamamos aportación, a cambio se desbloquean funciones.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Qué obtienes</h2>
|
||||||
|
<p>
|
||||||
|
Una aportación desbloquea los extras durante el periodo pagado: actualmente el clima histórico
|
||||||
|
con comparación frente a las normales climáticas y la perspectiva estacional de los próximos
|
||||||
|
meses, además de las nuevas funciones que vayan llegando. Las partes gratuitas de Drizz.li
|
||||||
|
siguen siendo gratuitas para todos.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Cómo funciona</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Solicitas los datos de la transferencia en la página de registro; te los enviamos por correo
|
||||||
|
junto con una referencia de pago personal.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Transfieres el importe (desde 3 € / 3 $ / 3 CHF al mes) mediante una transferencia bancaria
|
||||||
|
ordinaria, incluyendo la referencia.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Las transferencias se concilian manualmente. Cuando llega tu aportación - normalmente en 24
|
||||||
|
horas, como mucho unos días - recibes por correo una clave de acceso personal.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Pegas la clave en Drizz.li una vez; se guarda en tu dispositivo y se verifica automáticamente.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Sin renovación automática</h2>
|
||||||
|
<p>
|
||||||
|
Las aportaciones son únicas y prepagadas: nada se renueva solo y nunca te cobramos. Cuando
|
||||||
|
termina tu periodo, los extras simplemente se bloquean de nuevo; volver a aportar con el mismo
|
||||||
|
correo amplía tu clave existente.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Desistimiento y reembolsos</h2>
|
||||||
|
<p>
|
||||||
|
Si eres consumidor en la UE/EEE, dispones de un derecho legal de desistimiento de 14 días. Más
|
||||||
|
allá de eso, lo mantenemos sencillo: si no estás satisfecho por cualquier motivo en los 14 días
|
||||||
|
siguientes a recibir tu clave, escribe a
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
y te reembolsaremos la aportación íntegra.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Uso razonable</h2>
|
||||||
|
<p>
|
||||||
|
La clave de acceso es personal. No la publiques ni la compartas; las claves claramente abusadas
|
||||||
|
(por ejemplo, compartidas públicamente) pueden revocarse. Si tu clave se revoca sin causa,
|
||||||
|
tienes derecho a un reembolso proporcional.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Servicio y disponibilidad</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li es un proyecto personal de código abierto, ofrecido tal cual. Trabajamos para
|
||||||
|
mantenerlo disponible y preciso, pero no podemos garantizar disponibilidad ininterrumpida ni la
|
||||||
|
exactitud de los pronósticos: los datos meteorológicos son solo informativos (véase el descargo
|
||||||
|
del aviso legal). Si los extras dejan de estar disponibles de forma permanente durante un
|
||||||
|
periodo que has pagado, reembolsaremos el periodo restante a petición.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Datos</h2>
|
||||||
|
<p>
|
||||||
|
Cómo tratamos tus datos (correo, referencia de pago, datos de la transferencia) se describe en
|
||||||
|
la
|
||||||
|
<a href={href('/legal/privacy')}>política de privacidad</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Legislación aplicable</h2>
|
||||||
|
<p>
|
||||||
|
Estas condiciones se rigen por la legislación de [Suiza], sin perjuicio de las disposiciones
|
||||||
|
imperativas de protección al consumidor de tu país de residencia.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
En caso de discrepancia prevalece la versión inglesa de estas condiciones.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Conditions contributeur" subtitle="Dernière mise à jour : 1er août 2026">
|
||||||
|
<p>
|
||||||
|
Drizz.li est gratuit. Ces conditions portent sur la contribution facultative : un petit paiement
|
||||||
|
qui aide à faire vivre le projet et qui, en remerciement, débloque les bonus. Même si nous
|
||||||
|
parlons de contribution, des fonctionnalités sont débloquées en retour.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Ce que vous obtenez</h2>
|
||||||
|
<p>
|
||||||
|
Une contribution débloque les bonus pour la période payée : actuellement la météo historique
|
||||||
|
avec comparaison aux normales climatiques et l'aperçu saisonnier des mois à venir, ainsi que les
|
||||||
|
nouvelles fonctions à venir. Les parties gratuites de Drizz.li le restent pour tout le monde.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Fonctionnement</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Vous demandez les coordonnées bancaires sur la page d'inscription ; nous vous les envoyons par
|
||||||
|
e-mail avec une référence de paiement personnelle.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Vous virez le montant (à partir de 3 € / 3 $ / 3 CHF par mois) par virement bancaire
|
||||||
|
ordinaire, en indiquant la référence.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Les virements sont rapprochés manuellement. Dès réception de votre contribution - en général
|
||||||
|
sous 24 heures, au plus quelques jours - vous recevez une clé d'accès personnelle par e-mail.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Vous collez la clé dans Drizz.li une seule fois ; elle est stockée sur votre appareil et
|
||||||
|
vérifiée automatiquement.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Pas de reconduction automatique</h2>
|
||||||
|
<p>
|
||||||
|
Les contributions sont ponctuelles et prépayées : rien ne se reconduit et nous ne vous prélevons
|
||||||
|
jamais. À la fin de votre période, les bonus se reverrouillent simplement ; contribuer à nouveau
|
||||||
|
avec la même adresse prolonge votre clé existante.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Rétractation et remboursement</h2>
|
||||||
|
<p>
|
||||||
|
Si vous êtes consommateur dans l'UE/EEE, vous disposez d'un droit légal de rétractation de 14
|
||||||
|
jours. Au-delà, nous faisons simple : si vous n'êtes pas satisfait, pour quelque raison que ce
|
||||||
|
soit, dans les 14 jours suivant la réception de votre clé, écrivez à
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
et nous vous remboursons intégralement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Usage loyal</h2>
|
||||||
|
<p>
|
||||||
|
La clé d'accès est personnelle. Merci de ne pas la publier ni la partager ; les clés
|
||||||
|
manifestement détournées (par exemple partagées publiquement) peuvent être révoquées. Si votre
|
||||||
|
clé est révoquée sans motif, vous avez droit à un remboursement au prorata.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Service et disponibilité</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li est un projet personnel open source, fourni en l'état. Nous nous efforçons de le
|
||||||
|
maintenir disponible et exact, mais nous ne pouvons garantir ni une disponibilité ininterrompue
|
||||||
|
ni l'exactitude des prévisions - les données météo sont purement informatives (voir
|
||||||
|
l'avertissement dans les mentions légales). Si les bonus devenaient définitivement indisponibles
|
||||||
|
pendant une période payée, nous rembourserions la période restante sur demande.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Données</h2>
|
||||||
|
<p>
|
||||||
|
La façon dont nous traitons vos données (e-mail, référence de paiement, informations de
|
||||||
|
virement) est décrite dans la
|
||||||
|
<a href={href('/legal/privacy')}>politique de confidentialité</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Droit applicable</h2>
|
||||||
|
<p>
|
||||||
|
Ces conditions sont régies par le droit de la [Suisse], sans préjudice des dispositions
|
||||||
|
impératives de protection des consommateurs de votre pays de résidence.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
En cas de divergence, la version anglaise de ces conditions fait foi.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProsePage from '$lib/components/prose-page.svelte';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProsePage title="Condizioni per i sostenitori" subtitle="Ultimo aggiornamento: 1 agosto 2026">
|
||||||
|
<p>
|
||||||
|
Drizz.li è gratuito. Queste condizioni riguardano il contributo facoltativo di sostegno: un
|
||||||
|
piccolo pagamento che aiuta a mantenere vivo il progetto e che, come ringraziamento, sblocca gli
|
||||||
|
extra. Anche se lo chiamiamo contributo, in cambio vengono sbloccate delle funzioni.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Cosa ottieni</h2>
|
||||||
|
<p>
|
||||||
|
Un contributo sblocca gli extra per il periodo pagato: attualmente il meteo storico con
|
||||||
|
confronto rispetto alle normali climatiche e le prospettive stagionali per i mesi a venire,
|
||||||
|
oltre alle nuove funzioni man mano che arrivano. Le parti gratuite di Drizz.li restano gratuite
|
||||||
|
per tutti.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Come funziona</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Richiedi i dati per il bonifico nella pagina di iscrizione; te li inviamo via e-mail insieme a
|
||||||
|
un riferimento di pagamento personale.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Effettui il bonifico (da 3 € / 3 $ / 3 CHF al mese) con un normale bonifico bancario,
|
||||||
|
indicando il riferimento.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
I bonifici vengono abbinati manualmente. Appena il contributo arriva - di solito entro 24 ore,
|
||||||
|
al massimo pochi giorni - ricevi via e-mail una chiave di accesso personale.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Incolli la chiave in Drizz.li una volta sola; viene salvata sul tuo dispositivo e verificata
|
||||||
|
automaticamente.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Nessun rinnovo automatico</h2>
|
||||||
|
<p>
|
||||||
|
I contributi sono una tantum e prepagati: nulla si rinnova da solo e non ti addebitiamo mai
|
||||||
|
nulla. Alla fine del periodo gli extra si bloccano semplicemente di nuovo; un nuovo contributo
|
||||||
|
con la stessa e-mail estende la chiave esistente.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Recesso e rimborsi</h2>
|
||||||
|
<p>
|
||||||
|
Se sei un consumatore nell'UE/SEE, hai un diritto di recesso legale di 14 giorni. Oltre a
|
||||||
|
questo, la teniamo semplice: se per qualsiasi motivo non sei soddisfatto entro 14 giorni dalla
|
||||||
|
ricezione della chiave, scrivi a
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
|
e ti rimborseremo l'intero contributo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Uso corretto</h2>
|
||||||
|
<p>
|
||||||
|
La chiave di accesso è personale. Non pubblicarla né condividerla; le chiavi chiaramente abusate
|
||||||
|
(ad esempio condivise pubblicamente) possono essere revocate. Se la tua chiave viene revocata
|
||||||
|
senza motivo, hai diritto a un rimborso proporzionale.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Servizio e disponibilità</h2>
|
||||||
|
<p>
|
||||||
|
Drizz.li è un progetto personale open source, fornito così com'è. Lavoriamo per mantenerlo
|
||||||
|
disponibile e accurato, ma non possiamo garantire né la continuità del servizio né la
|
||||||
|
correttezza delle previsioni: i dati meteo sono solo informativi (vedi l'avvertenza nelle note
|
||||||
|
legali). Se gli extra dovessero diventare permanentemente non disponibili durante un periodo
|
||||||
|
pagato, rimborseremo su richiesta il periodo residuo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Dati</h2>
|
||||||
|
<p>
|
||||||
|
Il trattamento dei tuoi dati (e-mail, riferimento di pagamento, dati del bonifico) è descritto
|
||||||
|
nell'
|
||||||
|
<a href={href('/legal/privacy')}>informativa sulla privacy</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Legge applicabile</h2>
|
||||||
|
<p>
|
||||||
|
Queste condizioni sono regolate dal diritto della [Svizzera], fatte salve le disposizioni
|
||||||
|
imperative di tutela del consumatore del tuo paese di residenza.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
In caso di discrepanza prevale la versione inglese di queste condizioni.
|
||||||
|
</p>
|
||||||
|
</ProsePage>
|
||||||
@@ -1,9 +1,85 @@
|
|||||||
<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?: import('svelte').Snippet;
|
children?: 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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Joined here rather than in the markup: Svelte trims the whitespace around a
|
||||||
|
// line break, so a separator written as "{admin1},\n{country}" renders as
|
||||||
|
// "Canton of Schwyz,Switzerland". Elevation joins the same line; from lg up
|
||||||
|
// the whole line is hidden, because the topbar pill carries the region and
|
||||||
|
// elevation there.
|
||||||
|
let region = $derived(
|
||||||
|
[
|
||||||
|
location?.admin1,
|
||||||
|
location?.country,
|
||||||
|
location?.elevation != null ? `${Math.round(location.elevation)}m` : null
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if subtitle && location}
|
||||||
|
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||||
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
|
alt={location.country ?? ''}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||||
|
{location.name}
|
||||||
|
<span class="font-medium text-muted-foreground">· {subtitle}</span>
|
||||||
|
</h1>
|
||||||
|
{#if region}
|
||||||
|
<p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if actions}{@render actions()}{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
import { localizeHref } from '$lib/paraglide/runtime';
|
||||||
|
|
||||||
|
// Same reasoning as the root redirect: resolved in the browser so the
|
||||||
|
// visitor's language is known (see routes/+page.svelte).
|
||||||
|
onMount(() => {
|
||||||
|
goto(localizeHref('/weather/week/'), { replaceState: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
|
||||||
throw redirect(303, '/weather/week/');
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
@@ -3,18 +3,19 @@
|
|||||||
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(
|
||||||
resolve('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
href('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||||
{
|
{
|
||||||
replaceState: true
|
replaceState: true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||||
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
|
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||||
|
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
import { 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';
|
||||||
|
|
||||||
@@ -36,6 +45,11 @@
|
|||||||
|
|
||||||
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 || loadError != 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.
|
||||||
@@ -44,10 +58,12 @@
|
|||||||
storedLocation.set(data.location);
|
storedLocation.set(data.location);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = 'ncep_gefs_seamless';
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
|
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
|
||||||
models: ['ncep_gefs_seamless']
|
models: [DEFAULT_MODEL]
|
||||||
});
|
});
|
||||||
|
|
||||||
// units live in a persisted store; mirror them into params so a change
|
// units live in a persisted store; mirror them into params so a change
|
||||||
@@ -72,11 +88,19 @@
|
|||||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// preselect the persisted ensemble model (client-only, keeps SSR stable)
|
// a shared link carries its model; otherwise fall back to the stored choice
|
||||||
params.models = [get(storedEnsembleModel)];
|
const fromUrl = get(page).url.searchParams.get('model');
|
||||||
|
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({ model: unlessDefault(model, DEFAULT_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));
|
||||||
|
|
||||||
@@ -272,40 +296,20 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
|
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
|
||||||
|
{#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 w-full items-center gap-3 sm:w-auto">
|
||||||
<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">
|
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
selectedModel={params.models?.[0] ?? DEFAULT_MODEL}
|
||||||
groups={ensembleModelGroups}
|
groups={ensembleModelGroups}
|
||||||
label="Ensemble model"
|
label={m.model_ensemble()}
|
||||||
onModelChange={(model) => {
|
onModelChange={(model) => {
|
||||||
params.models = [model];
|
params.models = [model];
|
||||||
storedEnsembleModel.set(model);
|
storedEnsembleModel.set(model);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/snippet}
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
@@ -327,8 +331,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
|
{m.ensemble_trimmed({ days: validDays })}
|
||||||
trimmed to its available range.
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -341,42 +344,52 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if fetchedData}
|
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
|
||||||
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
<div class="relative">
|
||||||
the page margins (padded), the graphs bleed to the edges -->
|
{#if fetchedData}
|
||||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
||||||
{#each chartDefs as def, i (i)}
|
the page margins (padded), the graphs bleed to the edges -->
|
||||||
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
<div class="mb-1 px-3 lg:px-0">
|
{#each chartDefs as def, i (i)}
|
||||||
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||||
{#if def.subtitle}
|
<div class="mb-1 px-3 lg:px-0">
|
||||||
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||||
{/if}
|
{#if def.subtitle}
|
||||||
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||||
|
<CanvasChart
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={fetchedData.timezone}
|
||||||
|
series={def.series}
|
||||||
|
bands={fetchedData.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
||||||
|
yMin={def.yMin}
|
||||||
|
yMax={def.yMax}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
{/each}
|
||||||
<CanvasChart
|
</div>
|
||||||
bind:this={chartComponents[i]}
|
{:else}
|
||||||
timestamps={timestampsSec}
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||||
timezone={fetchedData.timezone}
|
<div in:fade={{ duration: 200 }} out:skeletonOut>
|
||||||
series={def.series}
|
<ChartContainer
|
||||||
bands={fetchedData.daylightBands}
|
loading
|
||||||
unit={def.unit}
|
chartCount={params.hourly?.length || 1}
|
||||||
showCredit={def.showCredit}
|
chartHeight={340}
|
||||||
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
bleed={false}
|
||||||
yMin={def.yMin}
|
/>
|
||||||
yMax={def.yMax}
|
</div>
|
||||||
{showLegend}
|
{/if}
|
||||||
height={300}
|
</div>
|
||||||
group={CHART_GROUP}
|
|
||||||
/>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
|
||||||
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||||
|
|
||||||
@@ -385,7 +398,9 @@
|
|||||||
{#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">Show legend</Label>
|
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||||
|
>{m.legend_show()}</Label
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ChartToolbar>
|
</ChartToolbar>
|
||||||
|
|||||||
@@ -3,18 +3,19 @@
|
|||||||
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(
|
||||||
resolve('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
href('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||||
{
|
{
|
||||||
replaceState: true
|
replaceState: true
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
metadata?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionGroup {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
options: Option[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: 'models' | 'variables';
|
||||||
|
groups: OptionGroup[];
|
||||||
|
selected: string[];
|
||||||
|
onToggle: (value: string) => void;
|
||||||
|
onToggleGroup: (values: string[], select: boolean) => void;
|
||||||
|
onRestoreDefaults?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { mode, groups, selected, onToggle, onToggleGroup, onRestoreDefaults }: Props = $props();
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
|
let onlySelected = $state(false);
|
||||||
|
let normalizedSearch = $derived(search.trim().toLocaleLowerCase());
|
||||||
|
|
||||||
|
let visibleGroups = $derived.by(() =>
|
||||||
|
groups.flatMap((group) => {
|
||||||
|
const options = group.options.filter((option) => {
|
||||||
|
if (onlySelected && !selected.includes(option.value)) return false;
|
||||||
|
if (!normalizedSearch) return true;
|
||||||
|
return `${group.label} ${option.label} ${option.value} ${option.metadata ?? ''}`
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
return options.length > 0 ? [{ ...group, options }] : [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function groupSelection(group: OptionGroup): { count: number; all: boolean; partial: boolean } {
|
||||||
|
const count = group.options.filter((option) => selected.includes(option.value)).length;
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
all: count === group.options.length,
|
||||||
|
partial: count > 0 && count < group.options.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-3 overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm">
|
||||||
|
<div class="border-b border-border/70 bg-muted/20 p-3 sm:p-4">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
bind:value={search}
|
||||||
|
placeholder={mode === 'models' ? m.compare_search_models() : m.compare_search_variables()}
|
||||||
|
aria-label={mode === 'models' ? m.compare_search_models() : m.compare_search_variables()}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex min-h-9 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border bg-background px-3 text-xs font-semibold hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Checkbox id="panel_{mode}_only_selected" bind:checked={onlySelected} />
|
||||||
|
<Label for="panel_{mode}_only_selected" class="cursor-pointer text-xs">
|
||||||
|
{m.compare_only_selected()}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 sm:p-4">
|
||||||
|
{#if visibleGroups.length > 0}
|
||||||
|
<div class="columns-1 gap-3 md:columns-2">
|
||||||
|
{#each visibleGroups as visibleGroup (visibleGroup.value)}
|
||||||
|
{@const fullGroup =
|
||||||
|
groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup}
|
||||||
|
{@const selection = groupSelection(fullGroup)}
|
||||||
|
<section
|
||||||
|
class="mb-3 inline-block w-full break-inside-avoid overflow-hidden rounded-xl border border-border/70 bg-background align-top"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-h-11 items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="panel_{mode}_group_{fullGroup.value}"
|
||||||
|
checked={selection.all}
|
||||||
|
indeterminate={selection.partial}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
onToggleGroup(
|
||||||
|
fullGroup.options.map((option) => option.value),
|
||||||
|
!selection.all
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
for="panel_{mode}_group_{fullGroup.value}"
|
||||||
|
class="min-w-0 flex-1 cursor-pointer text-sm font-bold"
|
||||||
|
>
|
||||||
|
{fullGroup.label}
|
||||||
|
</Label>
|
||||||
|
<span class="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{selection.count}/{fullGroup.options.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
{#each visibleGroup.options as option (option.value)}
|
||||||
|
<div class="flex min-h-10 items-center rounded-lg px-1.5 hover:bg-muted/60">
|
||||||
|
<Checkbox
|
||||||
|
id="panel_{mode}_{option.value}"
|
||||||
|
checked={selected.includes(option.value)}
|
||||||
|
onCheckedChange={() => onToggle(option.value)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
for="panel_{mode}_{option.value}"
|
||||||
|
class="min-w-0 flex-1 cursor-pointer py-2 pl-2"
|
||||||
|
>
|
||||||
|
<span class="block text-sm font-medium">{option.label}</span>
|
||||||
|
{#if option.metadata}
|
||||||
|
<span class="block text-[10px] text-muted-foreground">
|
||||||
|
{option.metadata}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{m.compare_no_matching_options()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mode === 'variables' && onRestoreDefaults}
|
||||||
|
<div class="border-t border-border bg-muted/20 px-3 py-3 sm:px-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="min-h-9 rounded-lg border border-border bg-background px-3 py-1.5 text-xs font-semibold hover:bg-muted"
|
||||||
|
onclick={onRestoreDefaults}
|
||||||
|
>
|
||||||
|
{m.compare_restore_defaults()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,152 +1,774 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatZoned, getZonedHour } from '$lib/utils/date';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import {
|
||||||
|
groupHover,
|
||||||
|
groupRange,
|
||||||
|
registerGroupMember,
|
||||||
|
setGroupHover,
|
||||||
|
setGroupRange
|
||||||
|
} from '$lib/charts';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
import { getWeatherIconName, hasWeatherIcon } from '../../utils/weather-codes';
|
||||||
|
import { modelColor, modelLabel } from './comparison';
|
||||||
|
|
||||||
|
import type { ModelSeriesData } from '$lib/services/weather';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
hourlyFlat: Record<string, number[]>;
|
models: ModelSeriesData[];
|
||||||
models: string[];
|
modelOrder: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { timestamps, hourlyFlat, models, sunrise, sunset, timezone }: Props = $props();
|
interface TimelineExporter {
|
||||||
|
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
let hourlyInterval = $state<1 | 3>(3);
|
interface TimelinePoint {
|
||||||
|
index: number;
|
||||||
|
time: number;
|
||||||
|
x: number;
|
||||||
|
hour: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
let filteredIndices = $derived(
|
interface DaySeparator {
|
||||||
timestamps.reduce<number[]>((acc, ts, i) => {
|
x: number;
|
||||||
if (hourlyInterval === 1 || getZonedHour(new Date(ts), timezone) % 3 === 0) {
|
}
|
||||||
acc.push(i);
|
|
||||||
}
|
interface SunlightSegment {
|
||||||
return acc;
|
x1: number;
|
||||||
}, [])
|
x2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipRow {
|
||||||
|
modelId: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
condition: string;
|
||||||
|
cloudCover: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
timestamps,
|
||||||
|
models,
|
||||||
|
modelOrder,
|
||||||
|
sunrise,
|
||||||
|
sunset,
|
||||||
|
timezone,
|
||||||
|
group,
|
||||||
|
plotInsetLeft,
|
||||||
|
plotInsetRight,
|
||||||
|
showModelNames,
|
||||||
|
onToggleModelNames,
|
||||||
|
registerExporter
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const HOUR = 3600;
|
||||||
|
const MIN_SPAN = 2 * HOUR;
|
||||||
|
const HEADER_H = 38;
|
||||||
|
const ROW_H = 38;
|
||||||
|
const FOOTER_H = 5;
|
||||||
|
const ICON_SIZE = 20;
|
||||||
|
const ICON_BOX_WIDTH = 20;
|
||||||
|
const ICON_BOX_HEIGHT = 30;
|
||||||
|
const MIN_ICON_GAP = 20;
|
||||||
|
const SUNLIGHT_STRIP_H = 4;
|
||||||
|
const EXPORT_TITLE_H = 30;
|
||||||
|
const SAMPLE_STEPS = [1, 2, 3, 4, 6, 8, 12, 24] as const;
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||||
|
let width = $state(0);
|
||||||
|
let themeVersion = $state(0);
|
||||||
|
let dragSelect = $state<{ x0: number; x1: number } | null>(null);
|
||||||
|
let screenRenderVersion = 0;
|
||||||
|
|
||||||
|
let timestampsSec = $derived(timestamps.map((timestamp) => timestamp / 1000));
|
||||||
|
let displayModels = $derived(
|
||||||
|
models.filter((model) => model.variables.weather_code?.some(hasWeatherIcon))
|
||||||
|
);
|
||||||
|
let sharedRange = $derived(groupRange(group));
|
||||||
|
let sharedHover = $derived(groupHover(group));
|
||||||
|
let tMin = $derived(timestampsSec[0] ?? 0);
|
||||||
|
let tMax = $derived(
|
||||||
|
timestampsSec.length > 1 ? timestampsSec[timestampsSec.length - 1] : tMin + HOUR
|
||||||
|
);
|
||||||
|
let viewStart = $derived(sharedRange ? Math.max(tMin, sharedRange.start) : tMin);
|
||||||
|
let viewEnd = $derived(
|
||||||
|
sharedRange ? Math.max(viewStart + MIN_SPAN / 2, Math.min(tMax, sharedRange.end)) : tMax
|
||||||
|
);
|
||||||
|
let zoomed = $derived(sharedRange !== null && viewEnd - viewStart < tMax - tMin);
|
||||||
|
let plotWidth = $derived(Math.max(1, width - plotInsetLeft - plotInsetRight));
|
||||||
|
let height = $derived(HEADER_H + displayModels.length * ROW_H + FOOTER_H);
|
||||||
|
|
||||||
|
function xForTime(time: number): number {
|
||||||
|
return plotInsetLeft + ((time - viewStart) / Math.max(1, viewEnd - viewStart)) * plotWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeForX(x: number): number {
|
||||||
|
return viewStart + ((x - plotInsetLeft) / Math.max(1, plotWidth)) * (viewEnd - viewStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPlotX(x: number): number {
|
||||||
|
return Math.max(plotInsetLeft, Math.min(width - plotInsetRight, x));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRange(start: number, end: number): void {
|
||||||
|
const fullSpan = tMax - tMin;
|
||||||
|
const span = Math.min(Math.max(end - start, MIN_SPAN), fullSpan);
|
||||||
|
if (span >= fullSpan) {
|
||||||
|
setGroupRange(group, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const boundedStart = Math.max(tMin, Math.min(start, tMax - span));
|
||||||
|
setGroupRange(group, { start: boundedStart, end: boundedStart + span });
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomAt(centerTime: number, factor: number): void {
|
||||||
|
const span = viewEnd - viewStart;
|
||||||
|
const nextSpan = span * factor;
|
||||||
|
const fraction = (centerTime - viewStart) / Math.max(1, span);
|
||||||
|
applyRange(centerTime - fraction * nextSpan, centerTime + (1 - fraction) * nextSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestIndex(values: number[], target: number): number {
|
||||||
|
let low = 0;
|
||||||
|
let high = values.length - 1;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = (low + high) >> 1;
|
||||||
|
if (values[middle] < target) low = middle + 1;
|
||||||
|
else high = middle;
|
||||||
|
}
|
||||||
|
if (low > 0 && Math.abs(values[low - 1] - target) <= Math.abs(values[low] - target)) {
|
||||||
|
return low - 1;
|
||||||
|
}
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDaytime(time: number): boolean {
|
||||||
|
for (let i = 0; i < sunrise.length; i++) {
|
||||||
|
if (time >= sunrise[i] && time < sunset[i]) return true;
|
||||||
|
const nextSunrise = sunrise[i + 1] ?? Infinity;
|
||||||
|
if (time >= sunset[i] && time < nextSunrise) return false;
|
||||||
|
}
|
||||||
|
return sunrise.length === 0 || time >= sunrise[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
let sampleStepHours = $derived.by(() => {
|
||||||
|
const pixelsPerHour = plotWidth / Math.max(1, (viewEnd - viewStart) / HOUR);
|
||||||
|
return SAMPLE_STEPS.find((step) => step * pixelsPerHour >= MIN_ICON_GAP) ?? 24;
|
||||||
|
});
|
||||||
|
|
||||||
|
let visiblePoints = $derived.by((): TimelinePoint[] => {
|
||||||
|
const points: TimelinePoint[] = [];
|
||||||
|
const edgeInset = ICON_SIZE / 2 + 1;
|
||||||
|
const firstValidIndex = timestampsSec.findIndex((time) => {
|
||||||
|
const x = xForTime(time);
|
||||||
|
return time >= viewStart && time <= viewEnd && x >= plotInsetLeft + edgeInset;
|
||||||
|
});
|
||||||
|
if (firstValidIndex < 0) return points;
|
||||||
|
|
||||||
|
const firstTime = timestampsSec[firstValidIndex];
|
||||||
|
const canvasRight = width - edgeInset;
|
||||||
|
let previousDay = '';
|
||||||
|
for (let index = firstValidIndex; index < timestampsSec.length; index++) {
|
||||||
|
const time = timestampsSec[index];
|
||||||
|
if (time < viewStart || time > viewEnd) continue;
|
||||||
|
const x = xForTime(time);
|
||||||
|
if (x > canvasRight) break;
|
||||||
|
const hoursFromFirst = Math.round((time - firstTime) / HOUR);
|
||||||
|
if (hoursFromFirst % sampleStepHours !== 0) continue;
|
||||||
|
const date = new Date(timestamps[index]);
|
||||||
|
const day = formatZoned(date, timezone, 'yyyy-MM-dd');
|
||||||
|
points.push({
|
||||||
|
index,
|
||||||
|
time,
|
||||||
|
x,
|
||||||
|
hour: formatZoned(date, timezone, 'HH'),
|
||||||
|
date: day === previousDay ? '' : formatZoned(date, timezone, 'EEE d')
|
||||||
|
});
|
||||||
|
previousDay = day;
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
});
|
||||||
|
|
||||||
|
let daySeparators = $derived.by((): DaySeparator[] => {
|
||||||
|
const separators: DaySeparator[] = [];
|
||||||
|
let previousDay = '';
|
||||||
|
for (let index = 0; index < timestampsSec.length; index++) {
|
||||||
|
const time = timestampsSec[index];
|
||||||
|
if (time < viewStart || time > viewEnd) continue;
|
||||||
|
const day = formatZoned(new Date(timestamps[index]), timezone, 'yyyy-MM-dd');
|
||||||
|
if (previousDay && day !== previousDay) separators.push({ x: xForTime(time) });
|
||||||
|
previousDay = day;
|
||||||
|
}
|
||||||
|
return separators;
|
||||||
|
});
|
||||||
|
let sunlightSegments = $derived.by((): SunlightSegment[] => {
|
||||||
|
const segments: SunlightSegment[] = [];
|
||||||
|
for (let index = 0; index < sunrise.length; index++) {
|
||||||
|
const start = Math.max(viewStart, sunrise[index]);
|
||||||
|
const end = Math.min(viewEnd, sunset[index] ?? sunrise[index]);
|
||||||
|
if (end <= start) continue;
|
||||||
|
segments.push({ x1: xForTime(start), x2: xForTime(end) });
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
});
|
||||||
|
|
||||||
|
let hoverX = $derived(
|
||||||
|
sharedHover !== null && sharedHover >= viewStart && sharedHover <= viewEnd
|
||||||
|
? xForTime(sharedHover)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
let hoverIndex = $derived(
|
||||||
|
sharedHover !== null && hoverX !== null && timestampsSec.length > 0
|
||||||
|
? nearestIndex(timestampsSec, sharedHover)
|
||||||
|
: -1
|
||||||
);
|
);
|
||||||
|
|
||||||
function checkNewDay(i: number, ts: number): boolean {
|
function conditionLabel(code: number): string {
|
||||||
if (i === 0) return false;
|
if (code === 0) return m.cond_clear();
|
||||||
const prevTs = timestamps[filteredIndices[i - 1]];
|
if (code === 1 || code === 2) return m.cond_fair();
|
||||||
return (
|
if (code === 3) return m.cond_cloudy();
|
||||||
formatZoned(new Date(ts), timezone, 'd') !== formatZoned(new Date(prevTs), timezone, 'd')
|
if (code === 45 || code === 48) return m.cond_fog();
|
||||||
|
if ([51, 53, 55, 56, 57].includes(code)) return m.cond_drizzle();
|
||||||
|
if ([71, 73, 75, 77, 85, 86].includes(code)) return m.cond_snow();
|
||||||
|
if ([95, 96, 99].includes(code)) return m.cond_thunder();
|
||||||
|
return m.cond_rain();
|
||||||
|
}
|
||||||
|
|
||||||
|
let tooltipRows = $derived.by((): TooltipRow[] => {
|
||||||
|
if (hoverIndex < 0) return [];
|
||||||
|
const rows: TooltipRow[] = [];
|
||||||
|
for (const model of displayModels) {
|
||||||
|
const code = model.variables.weather_code?.[hoverIndex];
|
||||||
|
if (!hasWeatherIcon(code)) continue;
|
||||||
|
const cloudCover = model.variables.cloud_cover?.[hoverIndex];
|
||||||
|
rows.push({
|
||||||
|
modelId: model.modelId,
|
||||||
|
label: modelLabel(model.modelId),
|
||||||
|
color: modelColor(model.modelId, modelOrder),
|
||||||
|
condition: conditionLabel(code),
|
||||||
|
cloudCover:
|
||||||
|
cloudCover !== undefined && Number.isFinite(cloudCover)
|
||||||
|
? Math.round(Math.max(0, Math.min(100, cloudCover)))
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
let tooltipVisible = $derived(hoverIndex >= 0 && tooltipRows.length > 0 && hoverX !== null);
|
||||||
|
let tooltipFlip = $derived((hoverX ?? 0) > width * 0.55);
|
||||||
|
let hoverFrame = 0;
|
||||||
|
let queuedClientX = 0;
|
||||||
|
const pointers = new Map<number, { x: number; y: number }>();
|
||||||
|
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
|
||||||
|
let selectionStartX = 0;
|
||||||
|
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
|
||||||
|
let panStart: { x: number; start: number; end: number } | null = null;
|
||||||
|
let pinchStart: { distance: number; start: number; end: number } | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unregisterGroup = registerGroupMember(group);
|
||||||
|
const interactionElement = containerEl;
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) width = entry.contentRect.width;
|
||||||
|
});
|
||||||
|
if (interactionElement) observer.observe(interactionElement);
|
||||||
|
interactionElement?.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
|
||||||
|
const themeObserver = new MutationObserver(() => themeVersion++);
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'data-theme']
|
||||||
|
});
|
||||||
|
|
||||||
|
registerExporter?.({ getExportImage });
|
||||||
|
return () => {
|
||||||
|
interactionElement?.removeEventListener('wheel', handleWheel);
|
||||||
|
observer.disconnect();
|
||||||
|
themeObserver.disconnect();
|
||||||
|
unregisterGroup();
|
||||||
|
registerExporter?.(null);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function scheduleHover(clientX: number): void {
|
||||||
|
queuedClientX = clientX;
|
||||||
|
if (hoverFrame !== 0) return;
|
||||||
|
hoverFrame = requestAnimationFrame(() => {
|
||||||
|
hoverFrame = 0;
|
||||||
|
if (!containerEl || timestampsSec.length === 0) return;
|
||||||
|
const localX = queuedClientX - containerEl.getBoundingClientRect().left;
|
||||||
|
const time =
|
||||||
|
viewStart + ((localX - plotInsetLeft) / Math.max(1, plotWidth)) * (viewEnd - viewStart);
|
||||||
|
setGroupHover(
|
||||||
|
group,
|
||||||
|
localX >= plotInsetLeft && localX <= width - plotInsetRight
|
||||||
|
? timestampsSec[nearestIndex(timestampsSec, time)]
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent): void {
|
||||||
|
if (!containerEl) return;
|
||||||
|
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
if (pointers.size === 2) {
|
||||||
|
containerEl.setPointerCapture(event.pointerId);
|
||||||
|
const [first, second] = [...pointers.values()];
|
||||||
|
pinchStart = {
|
||||||
|
distance: Math.max(10, Math.abs(first.x - second.x)),
|
||||||
|
start: viewStart,
|
||||||
|
end: viewEnd
|
||||||
|
};
|
||||||
|
panStart = null;
|
||||||
|
dragSelect = null;
|
||||||
|
gesture = 'pinch';
|
||||||
|
setGroupHover(group, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.pointerType === 'mouse') {
|
||||||
|
containerEl.setPointerCapture(event.pointerId);
|
||||||
|
selectionStartX = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
|
||||||
|
dragSelect = null;
|
||||||
|
gesture = 'select';
|
||||||
|
event.preventDefault();
|
||||||
|
} else {
|
||||||
|
touchStart = { x: event.clientX, y: event.clientY, start: viewStart, end: viewEnd };
|
||||||
|
gesture = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent): void {
|
||||||
|
if (!containerEl) return;
|
||||||
|
if (pointers.has(event.pointerId)) {
|
||||||
|
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture === 'pinch' && pointers.size === 2 && pinchStart) {
|
||||||
|
const [first, second] = [...pointers.values()];
|
||||||
|
const distance = Math.max(10, Math.abs(first.x - second.x));
|
||||||
|
const scale = pinchStart.distance / distance;
|
||||||
|
const span = pinchStart.end - pinchStart.start;
|
||||||
|
const center = (pinchStart.start + pinchStart.end) / 2;
|
||||||
|
applyRange(center - (span * scale) / 2, center + (span * scale) / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture === 'select' && event.pointerType === 'mouse' && pointers.size === 1) {
|
||||||
|
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
|
||||||
|
if (dragSelect || Math.abs(x - selectionStartX) >= 3) {
|
||||||
|
dragSelect = { x0: selectionStartX, x1: x };
|
||||||
|
setGroupHover(group, null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
|
||||||
|
const deltaX = Math.abs(event.clientX - touchStart.x);
|
||||||
|
const deltaY = Math.abs(event.clientY - touchStart.y);
|
||||||
|
if (deltaX < 6 && deltaY < 6) return;
|
||||||
|
if (deltaY > deltaX) {
|
||||||
|
gesture = 'scroll';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gesture = zoomed ? 'pan' : 'inspect';
|
||||||
|
containerEl.setPointerCapture(event.pointerId);
|
||||||
|
if (gesture === 'pan') {
|
||||||
|
panStart = { x: event.clientX, start: touchStart.start, end: touchStart.end };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture === 'scroll') return;
|
||||||
|
if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) {
|
||||||
|
const deltaTime =
|
||||||
|
((panStart.x - event.clientX) / Math.max(1, plotWidth)) * (panStart.end - panStart.start);
|
||||||
|
applyRange(panStart.start + deltaTime, panStart.end + deltaTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointers.size <= 1) scheduleHover(event.clientX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent): void {
|
||||||
|
if (gesture === 'select' && event.pointerType === 'mouse' && dragSelect) {
|
||||||
|
const first = timeForX(dragSelect.x0);
|
||||||
|
const second = timeForX(dragSelect.x1);
|
||||||
|
applyRange(Math.min(first, second), Math.max(first, second));
|
||||||
|
}
|
||||||
|
dragSelect = null;
|
||||||
|
pointers.delete(event.pointerId);
|
||||||
|
if (pointers.size < 2) pinchStart = null;
|
||||||
|
if (pointers.size === 0) {
|
||||||
|
panStart = null;
|
||||||
|
touchStart = null;
|
||||||
|
gesture = 'none';
|
||||||
|
}
|
||||||
|
if (event.pointerType !== 'mouse') setGroupHover(group, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerLeave(): void {
|
||||||
|
if (pointers.size === 0) setGroupHover(group, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(event: WheelEvent): void {
|
||||||
|
if ((!event.ctrlKey && !event.metaKey) || !containerEl) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const x = clampPlotX(event.clientX - containerEl.getBoundingClientRect().left);
|
||||||
|
zoomAt(timeForX(x), event.deltaY < 0 ? 1 / 1.3 : 1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDoubleClick(): void {
|
||||||
|
setGroupRange(group, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
|
if (timestampsSec.length === 0) return;
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||||
|
const current = sharedHover ?? viewStart;
|
||||||
|
const index = nearestIndex(timestampsSec, current);
|
||||||
|
const delta = event.key === 'ArrowLeft' ? -1 : 1;
|
||||||
|
const next = Math.max(0, Math.min(timestampsSec.length - 1, index + delta));
|
||||||
|
setGroupHover(group, timestampsSec[next]);
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === '+' || event.key === '=') {
|
||||||
|
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1 / 1.3);
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === '-' || event.key === '_') {
|
||||||
|
zoomAt(sharedHover ?? (viewStart + viewEnd) / 2, 1.3);
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
setGroupRange(group, null);
|
||||||
|
setGroupHover(group, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (hoverFrame !== 0) cancelAnimationFrame(hoverFrame);
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||||
|
|
||||||
|
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
|
||||||
|
const key = `${name}|${color}`;
|
||||||
|
let promise = iconImageCache.get(key);
|
||||||
|
if (!promise) {
|
||||||
|
promise = fetch(`/images/weather-icons/${name}.svg`)
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then(
|
||||||
|
(svg) =>
|
||||||
|
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const colored = svg.replace(/<svg\b/, `<svg fill="${color}"`);
|
||||||
|
const url = URL.createObjectURL(new Blob([colored], { type: 'image/svg+xml' }));
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(image);
|
||||||
|
};
|
||||||
|
image.onerror = (error) => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
image.src = url;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
iconImageCache.set(key, promise);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportImage(opts?: {
|
||||||
|
title?: string;
|
||||||
|
}): Promise<HTMLCanvasElement | null> {
|
||||||
|
if (!containerEl || width <= 0 || displayModels.length === 0) return null;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const titleHeight = opts?.title ? EXPORT_TITLE_H : 0;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round((height + titleHeight) * dpr);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return null;
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
const styles = getComputedStyle(containerEl);
|
||||||
|
const color = (name: string, fallback: string): string =>
|
||||||
|
styles.getPropertyValue(name).trim() || fallback;
|
||||||
|
const background = color('--card', '#ffffff');
|
||||||
|
const foreground = color('--foreground', '#1f2937');
|
||||||
|
const muted = color('--muted-foreground', '#6b7280');
|
||||||
|
const grid = color('--border', 'rgba(0, 0, 0, 0.12)');
|
||||||
|
const sunlight = color('--chart-3', '#f6c453');
|
||||||
|
|
||||||
|
context.fillStyle = background;
|
||||||
|
context.fillRect(0, 0, width, height + titleHeight);
|
||||||
|
if (opts?.title) {
|
||||||
|
context.fillStyle = foreground;
|
||||||
|
context.font = '600 14px system-ui, -apple-system, sans-serif';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillText(opts.title, 4, EXPORT_TITLE_H / 2 + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const yOffset = titleHeight;
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.rect(plotInsetLeft, yOffset, plotWidth, height);
|
||||||
|
context.clip();
|
||||||
|
const sunlightY = yOffset + HEADER_H - SUNLIGHT_STRIP_H;
|
||||||
|
context.fillStyle = muted;
|
||||||
|
context.globalAlpha = 0.12;
|
||||||
|
context.fillRect(plotInsetLeft, sunlightY, plotWidth, SUNLIGHT_STRIP_H);
|
||||||
|
context.fillStyle = sunlight;
|
||||||
|
context.globalAlpha = 0.9;
|
||||||
|
for (const segment of sunlightSegments) {
|
||||||
|
context.fillRect(segment.x1, sunlightY, segment.x2 - segment.x1, SUNLIGHT_STRIP_H);
|
||||||
|
}
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.strokeStyle = grid;
|
||||||
|
context.lineWidth = 1;
|
||||||
|
for (const separator of daySeparators) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(separator.x, yOffset);
|
||||||
|
context.lineTo(separator.x, yOffset + height);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
|
||||||
|
context.font = '600 10px system-ui, -apple-system, sans-serif';
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
for (const point of visiblePoints) {
|
||||||
|
if (point.date) {
|
||||||
|
context.fillStyle = foreground;
|
||||||
|
context.textAlign = 'left';
|
||||||
|
context.fillText(point.date, Math.max(plotInsetLeft + 3, point.x + 3), yOffset + 9);
|
||||||
|
}
|
||||||
|
context.fillStyle = muted;
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.fillText(point.hour, point.x, yOffset + 27);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconNames = new Set<string>();
|
||||||
|
for (const model of displayModels) {
|
||||||
|
for (const point of visiblePoints) {
|
||||||
|
const code = model.variables.weather_code?.[point.index];
|
||||||
|
if (!hasWeatherIcon(code)) continue;
|
||||||
|
iconNames.add(getWeatherIconName(code, isDaytime(point.time)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const images = new Map<string, HTMLImageElement>();
|
||||||
|
await Promise.all(
|
||||||
|
[...iconNames].map(async (name) => {
|
||||||
|
try {
|
||||||
|
images.set(name, await loadColoredIcon(name, foreground));
|
||||||
|
} catch {
|
||||||
|
/* An individual missing icon should not prevent the report export. */
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
context.font = '600 11px system-ui, -apple-system, sans-serif';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
for (let row = 0; row < displayModels.length; row++) {
|
||||||
|
const model = displayModels[row];
|
||||||
|
const rowTop = yOffset + HEADER_H + row * ROW_H;
|
||||||
|
const rowCenter = rowTop + ROW_H / 2;
|
||||||
|
context.strokeStyle = grid;
|
||||||
|
context.globalAlpha = 0.55;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(0, rowTop);
|
||||||
|
context.lineTo(width, rowTop);
|
||||||
|
context.stroke();
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.fillStyle = modelColor(model.modelId, modelOrder);
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(9, rowCenter, 4, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
if (showModelNames) {
|
||||||
|
context.fillStyle = foreground;
|
||||||
|
context.textAlign = 'left';
|
||||||
|
context.fillText(
|
||||||
|
modelLabel(model.modelId),
|
||||||
|
18,
|
||||||
|
rowCenter,
|
||||||
|
Math.max(20, plotInsetLeft - 24)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
// The right chart inset is empty display space, so pictograms at the
|
||||||
|
// final chart coordinate may extend into it without being clipped.
|
||||||
|
context.rect(plotInsetLeft, rowTop, width - plotInsetLeft, ROW_H);
|
||||||
|
context.clip();
|
||||||
|
for (const point of visiblePoints) {
|
||||||
|
const code = model.variables.weather_code?.[point.index];
|
||||||
|
if (!hasWeatherIcon(code)) continue;
|
||||||
|
const cloudCover = model.variables.cloud_cover?.[point.index];
|
||||||
|
const name = getWeatherIconName(code, isDaytime(point.time));
|
||||||
|
const image = images.get(name);
|
||||||
|
if (cloudCover !== undefined && Number.isFinite(cloudCover)) {
|
||||||
|
const fraction = Math.max(0, Math.min(100, cloudCover)) / 100;
|
||||||
|
context.fillStyle = muted;
|
||||||
|
context.globalAlpha = 0.03 + 0.34 * Math.pow(fraction, 0.85);
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(
|
||||||
|
point.x - ICON_BOX_WIDTH / 2,
|
||||||
|
rowCenter - ICON_BOX_HEIGHT / 2,
|
||||||
|
ICON_BOX_WIDTH,
|
||||||
|
ICON_BOX_HEIGHT,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
context.fill();
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
if (image) {
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
point.x - ICON_SIZE / 2,
|
||||||
|
rowCenter - ICON_SIZE / 2,
|
||||||
|
ICON_SIZE,
|
||||||
|
ICON_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// The visible panel is drawn through the exact same export renderer. This
|
||||||
* Determines if it's daytime for each timestamp based on sunrise/sunset data.
|
// keeps icon sampling, cloud-cover boxes, labels, and plot geometry identical in
|
||||||
*/
|
// the page and downloaded PNG.
|
||||||
let allDaytimeFlags = $derived(
|
$effect(() => {
|
||||||
timestamps.map((ts) => {
|
const target = canvasEl;
|
||||||
const tsS = ts / 1000;
|
const currentWidth = width;
|
||||||
for (let i = 0; i < sunrise.length; i++) {
|
const currentHeight = height;
|
||||||
const s = sunrise[i];
|
void themeVersion;
|
||||||
const e = sunset[i];
|
void visiblePoints;
|
||||||
// Between sunrise and sunset of the same day
|
void daySeparators;
|
||||||
if (tsS >= s && tsS < e) return true;
|
void sunlightSegments;
|
||||||
// Between sunset of day i and sunrise of day i+1 (night)
|
void displayModels;
|
||||||
const nextS = sunrise[i + 1] || Infinity;
|
void showModelNames;
|
||||||
if (tsS >= e && tsS < nextS) return false;
|
if (!target || currentWidth <= 0 || currentHeight <= 0) return;
|
||||||
}
|
|
||||||
// Fallback: before the first sunrise
|
|
||||||
if (sunrise.length > 0 && tsS < sunrise[0]) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
const version = ++screenRenderVersion;
|
||||||
* Filters models that actually have weather_code data available in the response.
|
void getExportImage().then((rendered) => {
|
||||||
*/
|
if (!rendered || version !== screenRenderVersion || target !== canvasEl) return;
|
||||||
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
|
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}
|
||||||
<div class="mt-8">
|
<section
|
||||||
<div class="mb-4 flex items-center justify-between">
|
class="mt-7 -mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"
|
||||||
<h3 class="text-xl font-bold">Model Comparison Timeline</h3>
|
aria-labelledby="model-pictogram-title"
|
||||||
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
>
|
||||||
<span class="select-none text-muted-foreground">3h</span>
|
<div class="flex items-start justify-between gap-3 px-3 pt-3 pb-2 lg:px-4">
|
||||||
<button
|
<div class="min-w-0">
|
||||||
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
<h2 id="model-pictogram-title" class="text-sm font-bold tracking-tight">
|
||||||
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
{m.compare_timeline_title()}
|
||||||
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
</h2>
|
||||||
title="Toggle between 1-hour and 3-hour intervals"
|
<p class="text-xs text-muted-foreground">{m.compare_timeline_hint()}</p>
|
||||||
>
|
</div>
|
||||||
<span
|
<button
|
||||||
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
|
type="button"
|
||||||
{hourlyInterval === 1 ? 'left-5.5' : 'left-0.75'}"
|
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"
|
||||||
></span>
|
aria-pressed={showModelNames}
|
||||||
</button>
|
onclick={onToggleModelNames}
|
||||||
<span class="select-none text-muted-foreground">1h</span>
|
>
|
||||||
|
{showModelNames ? m.compare_hide_model_names() : m.compare_show_model_names()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="lg:px-4">
|
||||||
|
<div
|
||||||
|
bind:this={containerEl}
|
||||||
|
class="relative w-full select-none overflow-hidden"
|
||||||
|
style:touch-action="pan-y"
|
||||||
|
role="region"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label={m.compare_timeline_scroll_aria()}
|
||||||
|
onpointerdown={handlePointerDown}
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerup={handlePointerUp}
|
||||||
|
onpointercancel={handlePointerUp}
|
||||||
|
onpointerleave={handlePointerLeave}
|
||||||
|
ondblclick={handleDoubleClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
bind:this={canvasEl}
|
||||||
|
class="block w-full"
|
||||||
|
style:height="{height}px"
|
||||||
|
role="img"
|
||||||
|
aria-label={m.compare_timeline_caption()}
|
||||||
|
></canvas>
|
||||||
|
{#if dragSelect}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute top-0 z-10 border-x-2 border-primary/70 bg-primary/15"
|
||||||
|
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
|
||||||
|
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
|
||||||
|
style:height="{height}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if hoverX !== null}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute top-0 left-0 border-l border-dashed border-muted-foreground/70 will-change-transform"
|
||||||
|
style:height="{height}px"
|
||||||
|
style:transform="translateX({hoverX}px)"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if tooltipVisible && hoverX !== null && sharedHover !== null}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute z-20 w-max max-w-[calc(100%-1rem)] rounded-md border border-border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
|
||||||
|
style:top="{HEADER_H + 4}px"
|
||||||
|
style:left={tooltipFlip ? 'auto' : `${hoverX + 12}px`}
|
||||||
|
style:right={tooltipFlip ? '0.5rem' : 'auto'}
|
||||||
|
>
|
||||||
|
<div class="mb-1 font-semibold whitespace-nowrap">
|
||||||
|
{formatZoned(new Date(sharedHover * 1000), timezone, 'EEE d MMM HH:mm')}
|
||||||
|
</div>
|
||||||
|
{#each tooltipRows as row (row.modelId)}
|
||||||
|
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
|
||||||
|
<span class="size-2 shrink-0 rounded-full" style:background-color={row.color}
|
||||||
|
></span>
|
||||||
|
<span class="min-w-0 break-words">{row.label}:</span>
|
||||||
|
<span class="pl-2 text-right font-semibold whitespace-nowrap">
|
||||||
|
{row.condition}{#if row.cloudCover !== null}
|
||||||
|
· {m.var_cloud()} {row.cloudCover}%
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</section>
|
||||||
class="overflow-x-auto rounded-lg border border-border bg-card shadow-sm"
|
|
||||||
style="scrollbar-width: thin"
|
|
||||||
>
|
|
||||||
<table class="w-full border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-muted/30">
|
|
||||||
<th
|
|
||||||
class="sticky left-0 z-20 w-32 border-b border-r border-border bg-muted/95 p-2 text-left text-xs font-bold"
|
|
||||||
>
|
|
||||||
Model
|
|
||||||
</th>
|
|
||||||
{#each filteredIndices as idx, i (idx)}
|
|
||||||
{@const ts = timestamps[idx]}
|
|
||||||
{@const isNewDay = checkNewDay(i, ts)}
|
|
||||||
<th
|
|
||||||
class="min-w-11 border-b border-r border-border/50 p-2 text-center text-[10px] {isNewDay
|
|
||||||
? 'border-l-2 border-l-primary/30'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
<div class="font-bold">{formatZoned(new Date(ts), timezone, 'HH')}</div>
|
|
||||||
<div class="text-muted-foreground">
|
|
||||||
{isNewDay ? formatZoned(new Date(ts), timezone, 'EEE d') : ''}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each displayModels as model (model)}
|
|
||||||
<tr class="group hover:bg-muted/10">
|
|
||||||
<td
|
|
||||||
class="sticky left-0 z-10 border-b border-r border-border bg-card p-2 text-[11px] font-semibold group-hover:bg-muted/20"
|
|
||||||
>
|
|
||||||
{model.replace(/_/g, ' ')}
|
|
||||||
</td>
|
|
||||||
{#each filteredIndices as idx, i (idx)}
|
|
||||||
{@const ts = timestamps[idx]}
|
|
||||||
{@const codes = hourlyFlat[`weather_code_${model}`]}
|
|
||||||
{@const code = codes ? codes[idx] : 0}
|
|
||||||
{@const day = allDaytimeFlags[idx]}
|
|
||||||
{@const isNewDay = checkNewDay(i, ts)}
|
|
||||||
<td
|
|
||||||
class="border-b border-r border-border/30 p-1.5 text-center {isNewDay
|
|
||||||
? 'border-l-2 border-l-primary/20'
|
|
||||||
: ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}"
|
|
||||||
>
|
|
||||||
{@render weatherIcon(getWeatherIconName(code, day))}
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
cardinalDirection,
|
||||||
|
isWindDirection,
|
||||||
|
modelColor,
|
||||||
|
modelMean,
|
||||||
|
sanitizeList,
|
||||||
|
validDirectionValues
|
||||||
|
} from './comparison';
|
||||||
|
|
||||||
|
import type { ModelSeriesData } from '$lib/services/weather';
|
||||||
|
|
||||||
|
const models: ModelSeriesData[] = [
|
||||||
|
{
|
||||||
|
modelId: 'model-a',
|
||||||
|
resolvedModelId: 'model-a',
|
||||||
|
variables: {
|
||||||
|
precipitation: [1, Number.NaN, 3],
|
||||||
|
precipitation_probability: [80, 90, 100]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelId: 'model-b',
|
||||||
|
resolvedModelId: 'model-b',
|
||||||
|
variables: { precipitation: [3, Number.NaN, 5] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('multimodel comparison helpers', () => {
|
||||||
|
it('averages only the exact scalar variable and preserves missing values', () => {
|
||||||
|
expect(modelMean(models, 'precipitation', 3)).toEqual([2, null, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates and deduplicates shared URL selections', () => {
|
||||||
|
const allowed = new Set(['a', 'b']);
|
||||||
|
expect(sanitizeList(['a', 'unknown', 'a', 'b'], allowed)).toEqual(['a', 'b']);
|
||||||
|
expect(sanitizeList(['unknown'], allowed)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats every wind direction height as a direction series', () => {
|
||||||
|
expect(isWindDirection('wind_direction_10m')).toBe(true);
|
||||||
|
expect(isWindDirection('wind_direction_180m')).toBe(true);
|
||||||
|
expect(isWindDirection('wind_speed_10m')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops direction values outside the fixed 0–360 degree domain', () => {
|
||||||
|
expect(validDirectionValues([-1, 0, 90, 360, 361, Number.NaN])).toEqual([
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
90,
|
||||||
|
360,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]);
|
||||||
|
expect(cardinalDirection(0)).toBe('N');
|
||||||
|
expect(cardinalDirection(270)).toBe('W');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns deterministic model colors', () => {
|
||||||
|
expect(modelColor('ecmwf_ifs')).toBe(modelColor('ecmwf_ifs'));
|
||||||
|
expect(modelColor('ecmwf_ifs')).not.toBe(modelColor('gfs_global'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user