merge main

This commit is contained in:
Vincent van der Wal
2026-08-10 09:15:04 +02:00
55 changed files with 1890 additions and 612 deletions
+13
View File
@@ -33,3 +33,16 @@ AGENTS.md
# Claude Code scratch: throwaway probe scripts, never committed
/.scratch
# Empty stubs the Claude Code sandbox mounts over shell/editor dotfiles while it
# runs. They are not project files and keep sneaking into commits via `git add -A`.
/.bash_profile
/.bashrc
/.profile
/.zprofile
/.zshrc
/.gitconfig
/.gitmodules
/.ripgreprc
/.idea
/.mcp.json
+9 -2
View File
@@ -46,6 +46,12 @@ Pages that are not prerendered (unlisted cities, GPS coordinate routes like
and resolves the location client-side. Configure the server to serve
`404.html` for unknown paths.
Serve it as an **internal rewrite (200)**, not as an error page. `error_page
404 /404.html` sends the right body with a 404 status: the page works, but
every hard reload of an unprerendered URL logs a 404 in the network panel and
tells crawlers the page does not exist. `try_files` with a URI as its last
argument does an internal redirect instead, and answers 200.
### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
@@ -83,11 +89,12 @@ drizzli.example.com {
server {
server_name drizzli.example.com;
root /srv/drizzli;
error_page 404 /404.html;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
location / {
try_files $uri $uri/ =404;
# the trailing /404.html is a URI, so nginx rewrites internally and
# answers 200 - `error_page 404 /404.html` would answer 404 instead
try_files $uri $uri/index.html /404.html;
}
}
```
+38 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Tage-Vorhersage",
"nav_week": "Wochenvorhersage",
"nav_compare": "Modellvergleich",
"nav_14day": "14-Tage-Vorhersage",
"nav_seasonal": "Saisonal",
@@ -44,7 +44,7 @@
"day_today": "Heute",
"day_tomorrow": "Morgen",
"day_yesterday": "Gestern",
"page_week_subtitle": "7-Tage-Vorhersage",
"page_week_subtitle": "Wochenvorhersage",
"page_compare_subtitle": "Modellvergleich",
"page_14day_subtitle": "14-Tage-Ensemblevorhersage",
"page_seasonal_subtitle": "Saisonale Aussichten",
@@ -101,7 +101,7 @@
"period_morning": "morgens",
"period_afternoon": "nachmittags",
"period_evening": "in den Abendstunden",
"footer_tagline": "Schnelle, unkomplizierte Wettervorhersagen auf Basis offener Daten.",
"footer_tagline": "Schnelle, gratis, unkomplizierte Wettervorhersagen auf Basis offener Daten.",
"footer_data_by": "Wetterdaten von",
"footer_forecasts": "Vorhersagen",
"footer_popular": "Beliebte Orte",
@@ -260,6 +260,8 @@
"table_interval_aria": "Stundenintervall",
"table_now": "Jetzt",
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
"page_loading": "Wird geladen…",
"page_loading_dismiss": "Schließen",
"charts_loading": "Diagramme werden geladen…",
"chart_download": "Meteogramm als PNG-Bild herunterladen",
"chart_credit_viz": "Visualisierung von",
@@ -267,7 +269,7 @@
"legend_show": "Legende anzeigen",
"meteograms_none_before": "Keine Meteogramme konfiguriert,",
"meteograms_none_action": "fügen Sie Variablen hinzu",
"meteograms_none_historical": "Keine Meteogramme konfiguriert. Fügen Sie Variablen auf der 7-Tage-Seite hinzu.",
"meteograms_none_historical": "Keine Meteogramme konfiguriert. Fügen Sie Variablen auf der Wochenvorhersage-Seite hinzu.",
"search_searching": "Wird gesucht…",
"search_favorites": "Favoriten",
"search_recent": "Zuletzt",
@@ -280,6 +282,7 @@
"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",
@@ -385,5 +388,35 @@
"cadence_varies": "variiert",
"model_group_automatic": "Automatisch",
"model_group_reanalysis": "ECMWF-Reanalyse",
"model_group_regional_reanalysis": "Regionale Reanalyse"
"model_group_regional_reanalysis": "Regionale Reanalyse",
"wmo_0": "Klarer Himmel",
"wmo_1": "Überwiegend klar",
"wmo_2": "Teils bewölkt",
"wmo_3": "Bedeckt",
"wmo_45": "Nebel",
"wmo_48": "Gefrierender Nebel",
"wmo_51": "Leichter Nieselregen",
"wmo_53": "Mäßiger Nieselregen",
"wmo_55": "Starker Nieselregen",
"wmo_56": "Leichter gefrierender Nieselregen",
"wmo_57": "Starker gefrierender Nieselregen",
"wmo_61": "Leichter Regen",
"wmo_63": "Mäßiger Regen",
"wmo_65": "Starker Regen",
"wmo_66": "Leichter gefrierender Regen",
"wmo_67": "Starker gefrierender Regen",
"wmo_71": "Leichter Schneefall",
"wmo_73": "Mäßiger Schneefall",
"wmo_75": "Starker Schneefall",
"wmo_77": "Schneegriesel",
"wmo_80": "Leichte Regenschauer",
"wmo_81": "Mäßige Regenschauer",
"wmo_82": "Heftige Regenschauer",
"wmo_85": "Leichte Schneeschauer",
"wmo_86": "Starke Schneeschauer",
"wmo_95": "Gewitter",
"wmo_96": "Gewitter mit leichtem Hagel",
"wmo_99": "Gewitter mit starkem Hagel",
"summary_read_more": "Mehr anzeigen",
"summary_read_less": "Weniger anzeigen"
}
+38 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "7-Day Forecast",
"nav_week": "Weekly Forecast",
"nav_compare": "Model Comparison",
"nav_14day": "14-Day Forecast",
"nav_seasonal": "Seasonal",
@@ -44,7 +44,7 @@
"day_today": "Today",
"day_tomorrow": "Tomorrow",
"day_yesterday": "Yesterday",
"page_week_subtitle": "7-day forecast",
"page_week_subtitle": "Weekly forecast",
"page_compare_subtitle": "Model comparison",
"page_14day_subtitle": "14-day ensemble forecast",
"page_seasonal_subtitle": "Seasonal outlook",
@@ -101,7 +101,7 @@
"period_morning": "in the morning",
"period_afternoon": "in the afternoon",
"period_evening": "in the evening",
"footer_tagline": "Fast, no-nonsense weather forecasts built on open data.",
"footer_tagline": "Fast, free, no-nonsense weather forecasts built on open data.",
"footer_data_by": "Weather data by",
"footer_forecasts": "Forecasts",
"footer_popular": "Popular locations",
@@ -260,6 +260,8 @@
"table_interval_aria": "Hourly interval",
"table_now": "Now",
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
"page_loading": "Loading…",
"page_loading_dismiss": "Dismiss",
"charts_loading": "Loading charts...",
"chart_download": "Download meteogram as PNG image",
"chart_credit_viz": "visualisation by",
@@ -267,7 +269,7 @@
"legend_show": "Show legend",
"meteograms_none_before": "No meteograms configured,",
"meteograms_none_action": "add some variables",
"meteograms_none_historical": "No meteograms configured. Add variables from the 7-day forecast page.",
"meteograms_none_historical": "No meteograms configured. Add variables from the weekly forecast page.",
"search_searching": "Searching...",
"search_favorites": "Favorites",
"search_recent": "Recent",
@@ -280,6 +282,7 @@
"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",
@@ -385,5 +388,35 @@
"cadence_varies": "varies",
"model_group_automatic": "Automatic",
"model_group_reanalysis": "ECMWF reanalysis",
"model_group_regional_reanalysis": "Regional reanalysis"
"model_group_regional_reanalysis": "Regional reanalysis",
"wmo_0": "Clear sky",
"wmo_1": "Mainly clear",
"wmo_2": "Partly cloudy",
"wmo_3": "Overcast",
"wmo_45": "Fog",
"wmo_48": "Depositing rime fog",
"wmo_51": "Light drizzle",
"wmo_53": "Moderate drizzle",
"wmo_55": "Dense drizzle",
"wmo_56": "Light freezing drizzle",
"wmo_57": "Dense freezing drizzle",
"wmo_61": "Slight rain",
"wmo_63": "Moderate rain",
"wmo_65": "Heavy rain",
"wmo_66": "Light freezing rain",
"wmo_67": "Heavy freezing rain",
"wmo_71": "Slight snowfall",
"wmo_73": "Moderate snowfall",
"wmo_75": "Heavy snowfall",
"wmo_77": "Snow grains",
"wmo_80": "Slight rain showers",
"wmo_81": "Moderate rain showers",
"wmo_82": "Violent rain showers",
"wmo_85": "Slight snow showers",
"wmo_86": "Heavy snow showers",
"wmo_95": "Thunderstorm",
"wmo_96": "Thunderstorm with slight hail",
"wmo_99": "Thunderstorm with heavy hail",
"summary_read_more": "Read more",
"summary_read_less": "Show less"
}
+41 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Pronóstico de 7 días",
"nav_week": "Pronóstico semanal",
"nav_compare": "Comparación de modelos",
"nav_14day": "Pronóstico de 14 días",
"nav_seasonal": "Estacional",
@@ -44,11 +44,11 @@
"day_today": "Hoy",
"day_tomorrow": "Mañana",
"day_yesterday": "Ayer",
"page_week_subtitle": "pronóstico de 7 días",
"page_compare_subtitle": "comparación de modelos",
"page_14day_subtitle": "pronóstico de conjunto a 14 días",
"page_seasonal_subtitle": "previsión estacional",
"page_historical_subtitle": "clima histórico",
"page_week_subtitle": "Pronóstico semanal",
"page_compare_subtitle": "Comparación de modelos",
"page_14day_subtitle": "Pronóstico de conjunto a 14 días",
"page_seasonal_subtitle": "Previsión estacional",
"page_historical_subtitle": "Clima histórico",
"hourly_heading": "por horas",
"hourly_variables": "Variables",
"meteograms_heading": "Meteogramas",
@@ -260,6 +260,8 @@
"table_interval_aria": "Intervalo horario",
"table_now": "Ahora",
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
"page_loading": "Cargando…",
"page_loading_dismiss": "Descartar",
"charts_loading": "Cargando gráficos…",
"chart_download": "Descargar el meteograma como imagen PNG",
"chart_credit_viz": "visualización de",
@@ -267,7 +269,7 @@
"legend_show": "Mostrar leyenda",
"meteograms_none_before": "No hay meteogramas configurados,",
"meteograms_none_action": "añade algunas variables",
"meteograms_none_historical": "No hay meteogramas configurados. Añade variables desde la página de 7 días.",
"meteograms_none_historical": "No hay meteogramas configurados. Añade variables desde la página del pronóstico semanal.",
"search_searching": "Buscando…",
"search_favorites": "Favoritos",
"search_recent": "Recientes",
@@ -280,6 +282,7 @@
"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",
@@ -385,5 +388,35 @@
"cadence_varies": "variable",
"model_group_automatic": "Automático",
"model_group_reanalysis": "Reanálisis del ECMWF",
"model_group_regional_reanalysis": "Reanálisis regional"
"model_group_regional_reanalysis": "Reanálisis regional",
"wmo_0": "Cielo despejado",
"wmo_1": "Mayormente despejado",
"wmo_2": "Parcialmente nublado",
"wmo_3": "Cubierto",
"wmo_45": "Niebla",
"wmo_48": "Niebla engelante",
"wmo_51": "Llovizna débil",
"wmo_53": "Llovizna moderada",
"wmo_55": "Llovizna intensa",
"wmo_56": "Llovizna engelante débil",
"wmo_57": "Llovizna engelante intensa",
"wmo_61": "Lluvia débil",
"wmo_63": "Lluvia moderada",
"wmo_65": "Lluvia intensa",
"wmo_66": "Lluvia engelante débil",
"wmo_67": "Lluvia engelante intensa",
"wmo_71": "Nevada débil",
"wmo_73": "Nevada moderada",
"wmo_75": "Nevada intensa",
"wmo_77": "Granos de nieve",
"wmo_80": "Chubascos débiles",
"wmo_81": "Chubascos moderados",
"wmo_82": "Chubascos violentos",
"wmo_85": "Chubascos de nieve débiles",
"wmo_86": "Chubascos de nieve intensos",
"wmo_95": "Tormenta",
"wmo_96": "Tormenta con granizo ligero",
"wmo_99": "Tormenta con granizo fuerte",
"summary_read_more": "Leer más",
"summary_read_less": "Mostrar menos"
}
+41 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Prévisions à 7 jours",
"nav_week": "Prévisions hebdomadaires",
"nav_compare": "Comparaison des modèles",
"nav_14day": "Prévisions à 14 jours",
"nav_seasonal": "Saisonnier",
@@ -44,11 +44,11 @@
"day_today": "Aujourd'hui",
"day_tomorrow": "Demain",
"day_yesterday": "Hier",
"page_week_subtitle": "prévisions à 7 jours",
"page_compare_subtitle": "comparaison des modèles",
"page_14day_subtitle": "prévision d'ensemble à 14 jours",
"page_seasonal_subtitle": "tendances saisonnières",
"page_historical_subtitle": "météo historique",
"page_week_subtitle": "Prévisions hebdomadaires",
"page_compare_subtitle": "Comparaison des modèles",
"page_14day_subtitle": "Prévision d'ensemble à 14 jours",
"page_seasonal_subtitle": "Tendances saisonnières",
"page_historical_subtitle": "Météo historique",
"hourly_heading": "par heure",
"hourly_variables": "Variables",
"meteograms_heading": "Météogrammes",
@@ -260,6 +260,8 @@
"table_interval_aria": "Intervalle horaire",
"table_now": "Maintenant",
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
"page_loading": "Chargement…",
"page_loading_dismiss": "Fermer",
"charts_loading": "Chargement des graphiques…",
"chart_download": "Télécharger le météogramme en PNG",
"chart_credit_viz": "visualisation par",
@@ -267,7 +269,7 @@
"legend_show": "Afficher la légende",
"meteograms_none_before": "Aucun météogramme configuré,",
"meteograms_none_action": "ajoutez des variables",
"meteograms_none_historical": "Aucun météogramme configuré. Ajoutez des variables depuis la page 7 jours.",
"meteograms_none_historical": "Aucun météogramme configuré. Ajoutez des variables depuis la page des prévisions hebdomadaires.",
"search_searching": "Recherche…",
"search_favorites": "Favoris",
"search_recent": "Récents",
@@ -280,6 +282,7 @@
"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",
@@ -385,5 +388,35 @@
"cadence_varies": "variable",
"model_group_automatic": "Automatique",
"model_group_reanalysis": "Réanalyse ECMWF",
"model_group_regional_reanalysis": "Réanalyse régionale"
"model_group_regional_reanalysis": "Réanalyse régionale",
"wmo_0": "Ciel dégagé",
"wmo_1": "Plutôt dégagé",
"wmo_2": "Partiellement nuageux",
"wmo_3": "Couvert",
"wmo_45": "Brouillard",
"wmo_48": "Brouillard givrant",
"wmo_51": "Bruine faible",
"wmo_53": "Bruine modérée",
"wmo_55": "Bruine dense",
"wmo_56": "Bruine verglaçante faible",
"wmo_57": "Bruine verglaçante dense",
"wmo_61": "Pluie faible",
"wmo_63": "Pluie modérée",
"wmo_65": "Pluie forte",
"wmo_66": "Pluie verglaçante faible",
"wmo_67": "Pluie verglaçante forte",
"wmo_71": "Neige faible",
"wmo_73": "Neige modérée",
"wmo_75": "Neige forte",
"wmo_77": "Grésil",
"wmo_80": "Averses faibles",
"wmo_81": "Averses modérées",
"wmo_82": "Averses violentes",
"wmo_85": "Averses de neige faibles",
"wmo_86": "Averses de neige fortes",
"wmo_95": "Orage",
"wmo_96": "Orage avec grêle légère",
"wmo_99": "Orage avec forte grêle",
"summary_read_more": "Lire la suite",
"summary_read_less": "Afficher moins"
}
+41 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_week": "Previsioni a 7 giorni",
"nav_week": "Previsioni settimanali",
"nav_compare": "Confronto modelli",
"nav_14day": "Previsioni a 14 giorni",
"nav_seasonal": "Stagionale",
@@ -44,11 +44,11 @@
"day_today": "Oggi",
"day_tomorrow": "Domani",
"day_yesterday": "Ieri",
"page_week_subtitle": "previsioni a 7 giorni",
"page_compare_subtitle": "confronto modelli",
"page_14day_subtitle": "previsione d'insieme a 14 giorni",
"page_seasonal_subtitle": "tendenze stagionali",
"page_historical_subtitle": "meteo storico",
"page_week_subtitle": "Previsioni settimanali",
"page_compare_subtitle": "Confronto modelli",
"page_14day_subtitle": "Previsione d'insieme a 14 giorni",
"page_seasonal_subtitle": "Tendenze stagionali",
"page_historical_subtitle": "Meteo storico",
"hourly_heading": "orario",
"hourly_variables": "Variabili",
"meteograms_heading": "Meteogrammi",
@@ -260,6 +260,8 @@
"table_interval_aria": "Intervallo orario",
"table_now": "Ora",
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
"page_loading": "Caricamento…",
"page_loading_dismiss": "Chiudi",
"charts_loading": "Caricamento dei grafici…",
"chart_download": "Scarica il meteogramma come immagine PNG",
"chart_credit_viz": "visualizzazione di",
@@ -267,7 +269,7 @@
"legend_show": "Mostra legenda",
"meteograms_none_before": "Nessun meteogramma configurato,",
"meteograms_none_action": "aggiungi qualche variabile",
"meteograms_none_historical": "Nessun meteogramma configurato. Aggiungi variabili dalla pagina a 7 giorni.",
"meteograms_none_historical": "Nessun meteogramma configurato. Aggiungi variabili dalla pagina delle previsioni settimanali.",
"search_searching": "Ricerca in corso…",
"search_favorites": "Preferiti",
"search_recent": "Recenti",
@@ -280,6 +282,7 @@
"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",
@@ -385,5 +388,35 @@
"cadence_varies": "variabile",
"model_group_automatic": "Automatico",
"model_group_reanalysis": "Rianalisi ECMWF",
"model_group_regional_reanalysis": "Rianalisi regionale"
"model_group_regional_reanalysis": "Rianalisi regionale",
"wmo_0": "Cielo sereno",
"wmo_1": "Prevalentemente sereno",
"wmo_2": "Parzialmente nuvoloso",
"wmo_3": "Coperto",
"wmo_45": "Nebbia",
"wmo_48": "Nebbia gelata",
"wmo_51": "Pioviggine debole",
"wmo_53": "Pioviggine moderata",
"wmo_55": "Pioviggine intensa",
"wmo_56": "Pioviggine congelantesi debole",
"wmo_57": "Pioviggine congelantesi intensa",
"wmo_61": "Pioggia debole",
"wmo_63": "Pioggia moderata",
"wmo_65": "Pioggia forte",
"wmo_66": "Pioggia congelantesi debole",
"wmo_67": "Pioggia congelantesi forte",
"wmo_71": "Nevicata debole",
"wmo_73": "Nevicata moderata",
"wmo_75": "Nevicata forte",
"wmo_77": "Granuli di neve",
"wmo_80": "Rovesci deboli",
"wmo_81": "Rovesci moderati",
"wmo_82": "Rovesci violenti",
"wmo_85": "Rovesci di neve deboli",
"wmo_86": "Rovesci di neve forti",
"wmo_95": "Temporale",
"wmo_96": "Temporale con grandine leggera",
"wmo_99": "Temporale con grandine forte",
"summary_read_more": "Leggi tutto",
"summary_read_less": "Mostra meno"
}
+5
View File
@@ -3,6 +3,11 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Declared in the shell, not in the layout head: the SPA fallback page
(404.html) ships no rendered head, so without this the browser falls
back to requesting /favicon.ico and takes a 404 on every load of an
unprerendered URL. -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<script>
// apply the persisted theme before first paint to avoid a flash
try {
+26 -9
View File
@@ -14,7 +14,8 @@
import { href, routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
import SupporterBadge from '$lib/supporter/SupporterBadge.svelte';
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
import SettingsMenu from './settings-menu.svelte';
import ThemeIcon from './theme-icon.svelte';
@@ -41,6 +42,21 @@
if (flagEl && !flagEl.src.endsWith(src)) flagEl.src = src;
});
// Built as a string rather than inline markup: the pieces are optional, and
// separators spelled out in the template lose their spacing to Svelte's
// whitespace trimming ("Canton of Schwyz,Switzerland").
let locationRegion = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
// elevation rides along in the pill's muted part ("· Canton of Schwyz,
// Switzerland · 465m"); a 0 m coastal town is a real reading, only a
// missing value is dropped
let locationDetail = $derived(
[locationRegion, location?.elevation != null ? `${Math.round(location.elevation)}m` : null]
.filter(Boolean)
.join(' · ')
);
// the pill ellipses, so the full name still has to be readable somewhere
let locationLine = $derived([location?.name, locationDetail].filter(Boolean).join(' · '));
const themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, () => string> = {
system: m.theme_follow_system,
@@ -99,8 +115,10 @@
<!-- Current location display -->
{#if location}
<!-- min-w-0 + a ceiling so a long "Sant Pere de Ribes, Catalonia, Spain"
ellipses inside the pill instead of pushing the search box off centre -->
<div
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
class="hidden min-w-0 max-w-70 items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex xl:max-w-96"
>
<img
bind:this={flagEl}
@@ -109,13 +127,10 @@
alt={location.country}
/>
<!-- full location (desktop); the page hero carries it on smaller screens -->
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
<span class="min-w-0 truncate text-sm font-semibold text-foreground" title={locationLine}>
{location.name}
{#if location.admin1 || location.country}
<span class="font-normal text-muted-foreground">
· {#if location.admin1}{location.admin1},&nbsp;
{/if}{location.country ?? ''}
</span>
{#if locationDetail}
<span class="font-normal text-muted-foreground">· {locationDetail}</span>
{/if}
</span>
</div>
@@ -144,8 +159,10 @@
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
so SupporterBadge still verifies the key on every viewport. -->
<div class="hidden items-center gap-3 md:flex">
<!-- Supporter status / unlock -->
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
{#if SUPPORTER_ENABLED}
<SupporterBadge />
{/if}
<!-- Language: the locale lives in the URL, so this is a set of links -->
<LanguageSelector />
@@ -6,9 +6,10 @@
import UnitOptions from '$lib/components/unit-options.svelte';
import * as m from '$lib/paraglide/messages';
import SupporterIcon from '$lib/paywall/SupporterIcon.svelte';
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import SupporterIcon from '$lib/supporter/SupporterIcon.svelte';
import UnlockDialog from '$lib/supporter/UnlockDialog.svelte';
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
import { isSupporter } from '$lib/supporter/store';
import ThemeIcon from './theme-icon.svelte';
@@ -76,6 +77,8 @@
<LanguageOptions onSelect={() => (open = false)} />
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
{#if SUPPORTER_ENABLED}
<div class="border-t border-border/70 pt-3">
<button
type="button"
@@ -87,7 +90,9 @@
>
<SupporterIcon
filled={$isSupporter}
class="h-4.5 w-4.5 shrink-0 {$isSupporter ? 'text-amber-500' : 'text-muted-foreground'}"
class="h-4.5 w-4.5 shrink-0 {$isSupporter
? 'text-amber-500'
: 'text-muted-foreground'}"
/>
<span class="min-w-0 flex-1">
<span class="block text-[13px] font-semibold">
@@ -99,6 +104,7 @@
</span>
</button>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
+22
View File
@@ -10,6 +10,18 @@ import { writable } from 'svelte/store';
*/
export const pageContentReady = writable(true);
/**
* Raised by the layout just before it captures a navigation away from the maps
* page. The map is a cross-origin iframe, which browsers do not paint into a
* view transition snapshot - captured bare, the outgoing page would carry a
* hole where the map was. The maps page answers by laying an opaque
* same-origin cover over the iframe (the same one that hides the map while it
* boots), so the snapshot shows a clean panel and the cross-fade has something
* real to fade from. The layout lowers it again once the navigation is
* through.
*/
export const mapTransitionCover = writable(false);
/**
* Called by the layout before it swaps to a route that fetches its own data.
*
@@ -22,6 +34,16 @@ export function markPageLoading(): void {
pageContentReady.set(false);
}
/**
* The counterpart, for a route that has nothing to wait for (the maps page, the
* legal pages). Without it a navigation away from a page that never resolved
* would leave the flag stuck on "loading", and the layout's overlay would sit on
* top of a page that is perfectly finished.
*/
export function markPageReady(): void {
pageContentReady.set(true);
}
/**
* Declare a page's readiness. Pass a getter for "my data has arrived". Only
* ever sets the flag - clearing it is the layout's job (see above).
@@ -5,7 +5,7 @@
import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { isSupporter, refreshSupporter } from './supporter';
import { isSupporter, refreshSupporter } from './store';
let open = $state(false);
@@ -6,7 +6,7 @@
import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
import { isSupporter, refreshSupporter, supporterState } from './supporter';
import { isSupporter, refreshSupporter, supporterState } from './store';
interface Props {
/** Short feature name shown in the locked panel headline. */
@@ -2,7 +2,7 @@
/**
* The supporter mark. A heart rather than a padlock: nothing here is locked
* away as a punishment - the extras are a thank-you for chipping in, and a
* padlock framed it as a paywall. Filled once someone is supporting,
* padlock framed it as a hard wall. Filled once someone is supporting,
* outlined as an invitation before that.
*/
interface Props {
@@ -9,13 +9,7 @@
import * as m from '$lib/paraglide/messages';
import { SIGNUP_URL, getSupporterPrice } from './config';
import {
clearLicense,
isSupporter,
storedLicenseKey,
supporterState,
verifyKey
} from './supporter';
import { clearLicense, isSupporter, storedLicenseKey, supporterState, verifyKey } from './store';
interface Props {
open?: boolean;
@@ -1,29 +1,40 @@
import * as m from '$lib/paraglide/messages';
/**
* Paywall configuration.
* Supporter configuration.
*
* The frontend stays fully static and open source; the only server piece is the
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
* build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a
* `.env` file), otherwise the sensible drizz.li defaults are used.
* build at your deployment with the `VITE_SUPPORTER_*` env vars (e.g. in a
* `.env` file), otherwise the sensible drizz.li defaults are used. The
* pre-rename `VITE_PAYWALL_*` names still work.
*/
const env = import.meta.env as Record<string, string | undefined>;
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
/**
* Master switch for the supporter features. While this is `false` everything
* supporter-related is parked: the nav entry points are hidden, no key is
* verified, and every gated page renders as if the visitor were a supporter.
* The logic stays in place set `VITE_SUPPORTER_ENABLED=true` (or default this
* to `true`) to bring it back.
*/
export const SUPPORTER_ENABLED = env.VITE_SUPPORTER_ENABLED === 'true';
/** Base URL of the self-hosted verify API (drizzli-paywall). */
export const PAYWALL_API_BASE = stripTrailingSlash(
env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
export const SUPPORTER_API_BASE = stripTrailingSlash(
env.VITE_SUPPORTER_API_BASE ?? env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
);
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
/** Where prospective supporters go to sign up (the verify API's signup form). */
export const SIGNUP_URL =
env.VITE_SUPPORTER_SIGNUP_URL ?? env.VITE_PAYWALL_SIGNUP_URL ?? `${SUPPORTER_API_BASE}/`;
// ─── Location-based pricing ──────────────────────────────────────────────────
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
// from their location. Mirror that here so the paywall copy matches. Detection
// from their location. Mirror that here so the supporter copy matches. Detection
// is timezone/locale based (no network geo lookup) and guarded for SSR.
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
@@ -54,7 +65,7 @@ export function detectCurrency(): SupporterCurrency {
return 'EUR';
}
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / Monat". */
/** Display price for the locked panel, e.g. "€3 / month" or "CHF 3 / Monat". */
export function getSupporterPrice(): string {
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
// deployment's .env keeps working.
@@ -8,7 +8,7 @@
*
* This gate is a convenience/honor-system gate: the frontend is open source and
* static, so it can be bypassed. Keeping the subscriber list server-side (in the
* paywall repo) is what makes it meaningful in practice.
* verify API's repo) is what makes it meaningful in practice.
*/
import { derived, get, writable } from 'svelte/store';
@@ -16,7 +16,7 @@ import { persisted } from 'svelte-persisted-store';
import * as m from '$lib/paraglide/messages';
import { PAYWALL_API_BASE } from './config';
import { SUPPORTER_API_BASE, SUPPORTER_ENABLED } from './config';
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
export const storedLicenseKey = persisted<string>('license_key', '');
@@ -75,10 +75,14 @@ function notExpired(expires: string | null | undefined): boolean {
* Whether supporter content should be shown. A live "valid"/"invalid" result
* wins; otherwise we fall back to the cached result (so a reload or a brief
* network blip doesn't lock a paying user out).
*
* While supporter features are parked (`SUPPORTER_ENABLED === false`) this is always
* true, so every gated page renders unlocked for everyone.
*/
export const isSupporter = derived(
[supporterState, storedSupporterCache],
([$state, $cache]): boolean => {
if (!SUPPORTER_ENABLED) return true;
if ($state.status === 'valid') return true;
if ($state.status === 'invalid') return false;
return !!($cache && $cache.valid && notExpired($cache.expires));
@@ -98,6 +102,10 @@ export interface VerifyResult {
* subscription can still show a "renew" state) but the cache is marked invalid.
*/
export async function verifyKey(key: string): Promise<VerifyResult> {
// Parked: never touch the verify API (and report success, since everything
// is unlocked anyway).
if (!SUPPORTER_ENABLED) return { valid: true, expires: null };
const trimmed = key.trim();
if (!trimmed) {
supporterState.set({ status: 'invalid' });
@@ -106,7 +114,7 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
supporterState.set({ status: 'checking' });
try {
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
const res = await fetch(`${SUPPORTER_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
headers: { accept: 'application/json' }
});
const data = (await res.json()) as VerifyResult;
@@ -135,6 +143,8 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
export async function refreshSupporter(): Promise<void> {
if (!SUPPORTER_ENABLED) return;
const key = get(storedLicenseKey);
if (!key) {
supporterState.set({ status: 'idle' });
+44 -10
View File
@@ -11,12 +11,40 @@ import type { Locale as DateFnsLocale } from 'date-fns';
// locale the messages do.
const DATE_LOCALES: Record<string, DateFnsLocale> = { en: enGB, de, es, fr, it };
/**
* Normalises anything date-shaped into a plain `Date`, or null when it does not
* describe a real instant.
*
* Two reasons every helper below starts here:
*
* 1. date-fns copies its input with `new date.constructor(value)`. Hand it the
* reactive `SvelteDate` the pages use for the selected day and it builds
* *another* SvelteDate, then reads the copy's fields back through memoised
* signals - a signal graph per formatted timestamp, in a path that runs
* hundreds of times per render.
* 2. date-fns throws `RangeError: Invalid time value` on an invalid date. Thrown
* from inside a render (or inside a view-transition callback, where it
* surfaces as an unhandled rejection) that takes the whole page down for what
* is really one unformattable cell.
*/
function plainDate(date: Date | null | undefined): Date | null {
const time = date?.getTime?.();
if (time == null || !Number.isFinite(time)) return null;
// already a plain Date: no copy needed
return date!.constructor === Date ? (date as Date) : new Date(time);
}
/**
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
*
* Returns '' for a date or zone it cannot format - callers compare these strings
* or print them, and both degrade gracefully on an empty one.
*/
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
return formatInTimeZone(date, timeZone, pattern, {
const d = plainDate(date);
if (!d || !timeZone) return '';
return formatInTimeZone(d, timeZone, pattern, {
locale: DATE_LOCALES[getLocale()] ?? enGB
});
}
@@ -26,16 +54,20 @@ export function formatZoned(date: Date, timeZone: string, pattern: string): stri
* Important for comparing weather forecast days against a selected date.
*/
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
const z1 = toZonedTime(date1, timeZone);
const z2 = toZonedTime(date2, timeZone);
return isSameDayDateFns(z1, z2);
const d1 = plainDate(date1);
const d2 = plainDate(date2);
if (!d1 || !d2 || !timeZone) return false;
return isSameDayDateFns(toZonedTime(d1, timeZone), toZonedTime(d2, timeZone));
}
/**
* Gets the numeric hour (0-23) for a date in a specific timezone.
* Gets the numeric hour (0-23) for a date in a specific timezone, or NaN when
* the date cannot be read.
*/
export function getZonedHour(date: Date, timeZone: string): number {
return parseInt(formatInTimeZone(date, timeZone, 'H'), 10);
const d = plainDate(date);
if (!d || !timeZone) return NaN;
return parseInt(formatInTimeZone(d, timeZone, 'H'), 10);
}
/**
@@ -43,9 +75,11 @@ export function getZonedHour(date: Date, timeZone: string): number {
* or a formatted date string, all relative to the target timezone.
*/
export function getRelativeDayLabel(date: Date, timeZone: string): string {
const now = new Date();
const zonedDate = toZonedTime(date, timeZone);
const zonedNow = toZonedTime(now, timeZone);
const d = plainDate(date);
if (!d || !timeZone) return '';
const zonedDate = toZonedTime(d, timeZone);
const zonedNow = toZonedTime(new Date(), timeZone);
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today();
@@ -57,7 +91,7 @@ export function getRelativeDayLabel(date: Date, timeZone: string): string {
yesterday.setDate(yesterday.getDate() - 1);
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
return formatZoned(date, timeZone, 'EEE d MMM');
return formatZoned(d, timeZone, 'EEE d MMM');
}
/**
+47 -11
View File
@@ -1,7 +1,12 @@
import { tick } from 'svelte';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
import {
canStartViewTransition,
prefersReducedMotion,
skipActiveViewTransition,
startViewTransition,
supportsViewTransitions
} from './view-transition';
/**
* Runs a day change inside a view transition, so the outgoing day is still on
@@ -12,26 +17,57 @@ const prefersReducedMotion = () =>
* `.day-region-*` in routes/layout.css); everything else - the strip, the
* header, the page chrome - is pinned by the `day-switch` class so it stays
* completely still.
*
* A day switch that lands while a navigation transition is still on screen just
* applies: starting a rival transition would skip the running one and flash the
* page (see view-transition.ts).
*/
/** Guards the shared cleanup below against a switch superseding a switch. */
let dayTransitionToken = 0;
export async function runDayTransition(update: () => void): Promise<void> {
if (prefersReducedMotion() || !document.startViewTransition) {
if (!canStartViewTransition()) {
update();
return;
}
const token = ++dayTransitionToken;
const root = document.documentElement;
root.classList.add('day-switch');
// The region snapshots include the part of the table normally scrolled up
// behind the sticky strip; the transition overlay is clipped at the bar's
// bottom edge so they cannot paint over the (live, clickable) strip.
// Measured per switch because the bar's height follows the scroll collapse.
const bar = document.querySelector('.daystrip .strip-row');
if (bar) {
const clip = Math.max(0, bar.getBoundingClientRect().bottom);
root.style.setProperty('--day-switch-clip', `${clip}px`);
}
// The page stays scrollable during the fade, but the snapshots and the clip
// line above are anchored to where things were at capture - so the first
// sign of scrolling finishes the fade on the spot instead of animating
// against a moving page.
const skip = () => skipActiveViewTransition();
window.addEventListener('wheel', skip, { passive: true });
window.addEventListener('touchmove', skip, { passive: true });
try {
// Svelte applies the change on the next tick; the transition has to wait
// for that before it snapshots the new state.
await document.startViewTransition(async () => {
// for that before it snapshots the new state. `day-switch` scopes which
// regions take part (see routes/layout.css) and is cleared when it ends.
await startViewTransition(
async () => {
update();
await tick();
}).finished;
} catch {
/* a superseded transition is fine - the DOM is already up to date */
},
{ rootClass: 'day-switch' }
);
} finally {
root.classList.remove('day-switch');
window.removeEventListener('wheel', skip);
window.removeEventListener('touchmove', skip);
// a newer switch owns the clip var now; only the last one may clear it
if (token === dayTransitionToken) root.style.removeProperty('--day-switch-clip');
}
}
@@ -45,7 +81,7 @@ export function daySwap(node: HTMLElement, key: unknown) {
const play = () => {
// view transitions handle it properly where they exist
if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return;
if (supportsViewTransitions() || prefersReducedMotion()) return;
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
duration: 460,
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
+24
View File
@@ -76,6 +76,16 @@ interface ResolveLocationOptions {
};
}
/**
* Geocoding results for a route segment, kept for the life of the process.
*
* A city's coordinates do not change, and the same segment is resolved over and
* over: once per per-location route during the prerender (five builds of the
* same lookup for every city), and again on every client-side hop from a city's
* week page to its comparison or archive.
*/
const resolvedLocations = new Map<string, GeoLocation>();
export async function resolveLocationFromRoute({
urlLocation,
routePrefix,
@@ -86,6 +96,11 @@ export async function resolveLocationFromRoute({
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
}
// The canonical-path check below still has to run per call (the same city is
// reached under different route prefixes), so only the lookup is cached.
const cached = resolvedLocations.get(urlLocation);
if (cached) return finishResolve(cached, routePrefix, event);
let urlLocationName: string;
let urlLocationId: string | undefined;
@@ -124,6 +139,15 @@ export async function resolveLocationFromRoute({
location = candidate;
}
resolvedLocations.set(urlLocation, location);
return finishResolve(location, routePrefix, event);
}
function finishResolve(
location: GeoLocation,
routePrefix: string,
event: ResolveLocationOptions['event']
): GeoLocation {
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
// every path with a trailing slash. Match that here or the equality check
// never holds and the redirect loops forever. The comparison also has to
+44
View File
@@ -0,0 +1,44 @@
import type { TransitionConfig } from 'svelte/transition';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* Fades a loading placeholder out *over* the content that replaces it.
*
* A plain `out:fade` is not usable on a skeleton: Svelte keeps the node in the
* layout for the length of the transition, so the real content stacks below the
* placeholder and the whole page jumps up the moment the placeholder finally
* unmounts. That is why these placeholders only ever faded in - the swap was
* abrupt, but at least nothing moved.
*
* This pins the placeholder to the box it already occupies and takes it out of
* flow for the fade instead. The content settles into its final position
* immediately and the skeleton dissolves on top of it.
*
* Requirements at the call site: the placeholder's parent must be positioned
* (`class="relative"`) and must be the same element that renders the real
* content, or the overlay lands somewhere else on the page.
*/
export function skeletonOut(node: HTMLElement, { duration = 260 } = {}): TransitionConfig {
if (prefersReducedMotion()) return { duration: 0 };
// measured while the node is still in flow, which is when Svelte builds the
// transition - the values below are what freeze it in place
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node;
return {
duration,
css: (t) => `
opacity: ${t};
position: absolute;
top: ${offsetTop}px;
left: ${offsetLeft}px;
width: ${offsetWidth}px;
height: ${offsetHeight}px;
margin: 0;
pointer-events: none;
z-index: 5;
`
};
}
+40 -7
View File
@@ -7,22 +7,31 @@
* history entry or re-runs a load - the back button still means "the page
* before", not "the previous day I clicked".
*
* Callers must pass the URL *untracked* (`get(page).url`, not `$page.url`).
* Reading it reactively inside the same effect that writes it creates a loop:
* replaceState publishes a new URL, the effect re-runs, writes again - which
* Svelte eventually kills with `effect_update_depth_exceeded`, hanging the page.
* The base is `location`, deliberately not `page.url`. Shallow routing does not
* republish the URL: `replaceState` writes the history entry (and files the
* *previous* `page.url` in it, so a popstate can restore it) but leaves
* `page.url` on the last navigated URL. Diffing against that stale value is
* wrong in exactly one direction - clearing a parameter. Opening a day writes
* `?day=`, `page.url` still has none, so asking to remove it produces a URL
* identical to the stale one, the write is skipped as a no-op, and the
* parameter stays in the address bar for good.
*
* Reading `location` rather than a passed-in URL also removes the old trap that
* callers had to pass it untracked: an effect that both read `$page.url` and
* wrote to it looped until `effect_update_depth_exceeded` hung the page.
*/
import { browser } from '$app/environment';
import { replaceState } from '$app/navigation';
export function syncSearchParams(url: URL, updates: Record<string, string | null>): void {
export function syncSearchParams(updates: Record<string, string | null>): void {
if (!browser) return;
const next = new URL(url);
const current = new URL(window.location.href);
const next = new URL(current);
for (const [key, value] of Object.entries(updates)) {
if (value == null || value === '') next.searchParams.delete(key);
else next.searchParams.set(key, value);
}
if (next.href === url.href) return;
if (next.href === current.href) return;
try {
replaceState(next, {});
} catch {
@@ -39,6 +48,30 @@ export function syncSearchParams(url: URL, updates: Record<string, string | null
}
}
/**
* The value to mirror, or null when it is the view's default. Defaults belong
* in the code, not the query string: a shared link should carry only what the
* visitor actually changed, and going back to the default has to clear the
* parameter again rather than pin the default in place.
*/
export function unlessDefault(value: string | null | undefined, fallback: string): string | null {
return value && value !== fallback ? value : null;
}
/**
* Same, for the comma-separated list parameters. Order counts - the models are
* plotted, coloured and legended in the order they are listed, so a reordered
* line-up is a different view even when it holds the same entries.
*/
export function listUnlessDefault(
values: string[] | null | undefined,
fallback: string[]
): string | null {
if (!values?.length) return null;
const joined = values.join(',');
return joined === fallback.join(',') ? null : joined;
}
/** Reads a comma-separated list, dropping empties. */
export function readList(url: URL, key: string): string[] | null {
const raw = url.searchParams.get(key);
+142
View File
@@ -0,0 +1,142 @@
/**
* Central entry point for every view transition in the app.
*
* Two things it exists to get right.
*
* **One transition at a time, but only where it matters.** Starting a
* transition while another is running makes the browser skip the first, and
* what that looks like depends entirely on which phase the first is in:
*
* - *capturing* - its update callback has not resolved yet, so the screen is
* frozen on the outgoing snapshot. Skipping drops that snapshot on the spot
* and pops the half-updated live DOM into view: the whole-screen flash.
* This phase is common here, because navigation deliberately holds the
* callback open while the new page fetches, and `/` plus every bare
* `/weather/<view>/` page is a redirect stub that navigates again from
* `onMount` - one click, two or three navigations.
* - *animating* - the DOM is already in its final state and the pseudo
* elements are playing out. Skipping just finishes them early, landing on
* exactly the state they were heading for.
*
* So a new transition rides along inside the running one only while it is
* capturing; once it is animating, superseding it is the better answer (waiting
* would strand the new update under a stale snapshot until the animation ends).
*
* **A scoping class.** `rootClass` is set on `<html>` for the life of the
* transition, so the stylesheet can tell a page swap from a day switch and pin
* the parts that are identical on both sides (see routes/layout.css).
*/
type UpdateCallback = () => void | Promise<void>;
interface Options {
/** Class set on `<html>` while the transition runs, for scoping CSS. */
rootClass?: string;
/**
* Set false to run the update without a transition. For content the browser
* does not paint into a snapshot - a cross-origin iframe - where animating
* means animating a hole rather than a cross-fade.
*/
enabled?: boolean;
}
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
let capturing: Promise<void> | null = null;
/**
* How many running transitions hold each scoping class. A superseded
* transition's cleanup fires while its successor is mid-capture (skipping
* rejects `finished` on a microtask, which runs before the next render);
* without the count it would strip the class out from under the successor,
* and a day switch captured without `day-switch` falls back to the full-page
* fade it exists to prevent.
*/
const rootClassHolds = new Map<string, number>();
/** The most recently started transition, while it is capturing or animating. */
let active: ViewTransition | null = null;
/**
* Finishes the running transition's animation on the spot (the DOM is already
* in its final state, so this is always safe). Used to hand the screen back
* the moment the user starts scrolling under a day switch, rather than
* animating against a moving target.
*/
export function skipActiveViewTransition(): void {
active?.skipTransition();
}
export const supportsViewTransitions = (): boolean =>
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
export const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* True while a transition is frozen on its outgoing snapshot - the phase in
* which starting a rival transition would flash the page.
*/
export const isViewTransitionCapturing = (): boolean => capturing !== null;
/** Whether `startViewTransition` would actually open one right now. */
export const canStartViewTransition = (): boolean =>
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionCapturing();
/**
* Runs `update` inside a view transition, or straight away when one cannot (or
* must not) be started. Resolves once the transition has finished animating -
* or as soon as the update is done, when it ran on its own.
*/
export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
const { rootClass, enabled = true } = options;
if (!enabled || !canStartViewTransition()) {
return Promise.resolve(update()).then(
() => {},
(error: unknown) => {
console.error('view transition update failed', error);
}
);
}
const root = document.documentElement;
if (rootClass) {
rootClassHolds.set(rootClass, (rootClassHolds.get(rootClass) ?? 0) + 1);
root.classList.add(rootClass);
}
const transition = document.startViewTransition(update);
active = transition;
// A throw inside the callback rejects `updateCallbackDone` as well as
// `finished`. Nothing awaits the former, and an unhandled rejection there is
// what turns one bad render into an "Uncaught" error on the page.
const captured = transition.updateCallbackDone.then(
() => {},
(error: unknown) => {
console.error('view transition update failed', error);
}
);
capturing = captured;
void captured.then(() => {
if (capturing === captured) capturing = null;
});
// A superseded transition rejects `finished`; the DOM is already up to date,
// so that is not an error worth surfacing.
return transition.finished
.then(
() => {},
() => {}
)
.finally(() => {
if (active === transition) active = null;
if (!rootClass) return;
const holds = (rootClassHolds.get(rootClass) ?? 1) - 1;
if (holds > 0) {
rootClassHolds.set(rootClass, holds);
} else {
rootClassHolds.delete(rootClass);
root.classList.remove(rootClass);
}
});
}
+224 -59
View File
@@ -6,16 +6,26 @@
import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
import {
mapTransitionCover,
markPageLoading,
markPageReady,
pageContentReady
} from '$lib/stores/page-transition.svelte';
import { storedTheme } from '$lib/stores/settings';
import {
canStartViewTransition,
startViewTransition,
supportsViewTransitions
} from '$lib/utils/view-transition';
import Footer from '$lib/components/navigation/footer.svelte';
import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
import favicon from '$lib/assets/favicon.svg';
import { routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import './layout.css';
@@ -31,7 +41,9 @@
const apply = () => {
const root = document.documentElement;
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
const paint = () => root.classList.toggle('dark', dark);
const paint = () => {
root.classList.toggle('dark', dark);
};
// The very first application is just painting the stored theme - only
// an actual switch afterwards is worth cross-fading.
@@ -42,9 +54,17 @@
return;
}
if (document.startViewTransition) {
if (canStartViewTransition() && !onMapsPage()) {
// one cross-fade of the whole document; component transitions untouched
document.startViewTransition(paint);
void startViewTransition(paint);
return;
}
// A navigation transition already owns the screen: repaint under it
// rather than skipping it (which would flash), and fall back to the
// colour transition below when the browser has none at all.
if (supportsViewTransitions()) {
paint();
return;
}
@@ -63,25 +83,91 @@
// ── Page cross-fade ───────────────────────────────────────────────────────
// A real cross-fade needs the outgoing page still on screen while the
// incoming one appears - so the view transition is opened at navigation and
// deliberately held open until the new page reports that its data has landed.
// The browser keeps showing the old snapshot for that whole time (rather than
// flashing a skeleton), then fades it into the finished page.
// held open for a short grace period, waiting for the new page to report that
// its data has landed. A cached or fast response lands inside that window and
// the old page fades straight into the finished one, no skeleton in between.
//
// The wait is capped: past the ceiling we cross-fade into whatever is on
// screen instead of freezing the UI on a slow network.
const READY_CEILING_MS = 2200;
const FADE_OUT_MS = 170;
// Past the grace period the wait gives up and the loading overlay takes over:
// holding the old page frozen any longer looks like a dead click, and the
// overlay is the honest answer - something is happening, it just isn't here
// yet. Note the order: the overlay has to be in the DOM *before* the
// transition captures the incoming state, because a running view transition
// freezes the page and nothing painted after that point can appear.
//
// There is deliberately no fade for browsers without view transitions. The
// old fallback dimmed the outgoing page to nothing and brought the new one
// back up, which on a single layer is a flash of the bare background rather
// than a cross-fade. Swapping outright and letting the overlay carry the
// "loading" message is quieter and honest.
const READY_HOLD_MS = 350;
// Nothing reports ready when a fetch fails outright, so the overlay needs its
// own way out rather than sitting on top of an error message forever.
const OVERLAY_CEILING_MS = 15000;
let contentVisible = $state(true);
let revealTimer = 0;
// The map is a cross-origin iframe, and a browser does not paint one into a
// view transition snapshot - captured bare, any transition with the maps page
// on either side would animate a hole where the map is. The way out is to
// make sure the map is never what gets captured: the maps page keeps an
// opaque cover over the iframe while the map boots (so an arrival fades into
// a clean panel, and the map fades up once ready), and raises the same cover
// again just before a departure is captured (see mapTransitionCover). Both
// snapshots then hold real pixels and the maps page transitions like any
// other route.
const MAPS_ROUTE = '/weather/maps';
const onMapsPage = () => routePath(get(page).url.pathname).startsWith(MAPS_ROUTE);
const reducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let loadingOverlay = $state(false);
let overlayCeilingTimer = 0;
// A view transition freezes the page, so a Svelte in-transition started under
// one cannot play - the overlay would be captured at opacity 0 and pop in
// afterwards. Inside a transition the cross-fade does the fading instead.
let overlayFadesIn = $state(true);
// Routes that fetch their own forecast after mounting. Knowing this up front
// is what makes the wait reliable: the layout clears the ready flag before
// the swap rather than trusting the incoming page to have done it.
const DATA_ROUTES = new Set([
function showOverlay(animate: boolean): void {
overlayFadesIn = animate;
loadingOverlay = true;
clearTimeout(overlayCeilingTimer);
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
}
function hideOverlay(): void {
clearTimeout(overlayCeilingTimer);
loadingOverlay = false;
}
/**
* Manual dismissal. The overlay reports on a fetch it does not control, so
* "stuck" is always a possibility (a page that never reports ready, a request
* that neither resolves nor rejects) - and the page behind it still works.
* Whatever was loading carries on; only the veil goes.
*/
function dismissOverlay(): void {
hideOverlay();
}
function onWindowKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && loadingOverlay) dismissOverlay();
}
/** The page that just mounted has its data: whatever we were waiting for is in. */
$effect(() => {
if ($pageContentReady) hideOverlay();
});
// Routes that are not finished on arrival: the five that fetch a forecast
// after mounting, the maps page (whose map is a cross-origin iframe that has
// to load first), and the redirect stubs, which render nothing at all and
// bounce to a located URL from `onMount`. Knowing this up front is what makes
// the wait reliable - the layout clears the ready flag before the swap rather
// than trusting the incoming page to have done it.
const PENDING_ROUTES = new Set([
'/',
'/weather/week',
'/weather/14-day',
'/weather/compare',
'/weather/seasonal',
'/weather/historical',
'/weather/maps',
'/weather/week/[location]',
'/weather/14-day/[location]',
'/weather/compare/[location]',
@@ -89,25 +175,67 @@
'/weather/historical/[location]'
]);
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
async function waitForContent(): Promise<void> {
const deadline = Date.now() + READY_CEILING_MS;
/**
* Resolves once the freshly mounted page has its data, or once the grace
* period is up - in which case the overlay goes up first, so it is part of
* the state the transition is about to snapshot.
*/
async function waitForContent(underTransition: boolean): Promise<void> {
const deadline = Date.now() + READY_HOLD_MS;
while (!get(pageContentReady) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 40));
await new Promise((resolve) => setTimeout(resolve, 20));
}
if (!get(pageContentReady)) showOverlay(!underTransition);
// one more frame so the page paints its data before the snapshot is taken
await tick();
}
onNavigate((navigation) => {
if (reducedMotion()) return;
/**
* True when the navigation lands on the route and params the page is already
* showing - the sidebar's home link from the week page it points at, or a
* link that differs only in the query string.
*
* SvelteKit keeps the page component mounted for those, and nothing it holds
* changes: its forecast is already fetched, so the readiness effect it
* registered never re-runs and never re-announces. Clearing the flag for such
* a navigation strands it cleared, and the overlay sits there until its
* ceiling. There is genuinely nothing to wait for, so don't clear it.
*/
function landsOnCurrentPage(navigation: {
from: { route: { id: string | null }; params: Record<string, string> | null } | null;
to: { route: { id: string | null }; params: Record<string, string> | null } | null;
}): boolean {
const from = navigation.from;
const to = navigation.to;
if (!from?.route.id || from.route.id !== to?.route.id) return false;
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
}
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
if (loadsData) markPageLoading();
onNavigate(async (navigation) => {
const pending =
PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
// Either way the flag is set explicitly: leaving a page that never resolved
// for one that has nothing to load would otherwise strand the overlay.
if (pending) markPageLoading();
else markPageReady();
// `startViewTransition` decides whether a transition is possible at all
// (support, reduced motion, one already capturing) and runs the update
// inline when it is not - so there is exactly one path from here down.
const underTransition = canStartViewTransition();
// Leaving the maps page: cover the iframe before the outgoing state is
// captured, so the snapshot holds a clean panel instead of a hole where
// the map was. The tick makes sure the cover is actually in the DOM by
// the time the capture reads it.
if (underTransition && navigation.from?.route?.id === MAPS_ROUTE) {
mapTransitionCover.set(true);
await tick();
}
if (typeof document.startViewTransition === 'function') {
return new Promise<void>((swap) => {
document.startViewTransition(async () => {
void startViewTransition(
async () => {
// hand control back so SvelteKit swaps the DOM underneath the
// frozen snapshot of the old page
swap();
@@ -115,40 +243,28 @@
// rejects this promise; that is not an error worth surfacing,
// and leaving it unhandled shows up as "navigation aborted".
await navigation.complete.catch(() => {});
if (loadsData) await waitForContent();
if (pending) await waitForContent(underTransition);
},
// pins the chrome that is the same on both sides (routes/layout.css)
{ rootClass: 'page-switch', enabled: underTransition }
);
});
});
}
// No view transitions: fall back to fading out, then back in on arrival.
clearTimeout(revealTimer);
contentVisible = false;
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
});
let mainEl = $state<HTMLElement | null>(null);
afterNavigate((navigation) => {
// The departure snapshot (if any) is taken by now, so the maps cover has
// done its job; lowering it here also means a later visit to the maps page
// starts from its own boot cover rather than a stuck one.
mapTransitionCover.set(false);
// The page scrolls inside <main>, not the window, so SvelteKit's own scroll
// handling never touches it and a new page would open half way down.
// Back/forward and in-page anchors keep their position.
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
mainEl?.scrollTo({ top: 0 });
}
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
contentVisible = true;
return;
}
const startedAt = Date.now();
const reveal = () => {
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
contentVisible = true;
return;
}
revealTimer = window.setTimeout(reveal, 60);
};
reveal();
});
// the maps page embeds a full-bleed map: no padding, no scrolling
@@ -171,14 +287,66 @@
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<!-- the icon itself lives in app.html, so the SPA fallback carries it too -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
indicator, not a modal, so the nav and the search stay usable while a slow
forecast is still on its way. It is also always dismissable - Escape or the
close button - because a veil nobody can get rid of is worse than no veil,
and the page underneath is perfectly usable either way. -->
<svelte:window onkeydown={onWindowKeydown} />
{#if loadingOverlay}
<div
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
in:fade={{ duration: overlayFadesIn ? 120 : 0 }}
out:fade={{ duration: 280 }}
role="status"
aria-live="polite"
>
<button
type="button"
class="pointer-events-auto absolute top-3 right-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:bg-muted hover:text-foreground md:top-4 md:right-4"
onclick={dismissOverlay}
aria-label={m.page_loading_dismiss()}
title={m.page_loading_dismiss()}
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<div
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
>
<svg
class="h-4 w-4 animate-spin text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="text-sm font-semibold">{m.page_loading()}</span>
</div>
</div>
{/if}
<div class="flex h-screen overflow-hidden bg-background text-foreground">
<!-- Desktop sidebar -->
<div class="hidden h-full shrink-0 md:block">
<div class="sidebar-region hidden h-full shrink-0 md:block">
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
</div>
@@ -221,10 +389,7 @@
{:else}
<!-- cap the content width on very large screens; the footer below
gives the page its ending, so only modest bottom room is needed -->
<div
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
class:page-fade-hidden={!contentVisible}
>
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
{@render children()}
</div>
<!-- full-bleed footer inside the scroll area (its own inner max-w),
+2 -1
View File
@@ -29,7 +29,8 @@
Drizz.li ist kostenlos nutzbar. Wenn es Ihnen nützt, können Sie es mit einem kleinen monatlichen
Beitrag unterstützen (ab 3 € / Monat) - das hilft, Domain, Hosting und Entwicklungszeit zu
decken. Als Dankeschön schalten Unterstützerinnen und Unterstützer die Extras frei: historisches
Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie erscheinen.
Wetter mit Vergleich zu den Klimanormalen, die saisonalen Aussichten für die kommenden Monate
sowie neue Funktionen, sobald sie erscheinen.
</p>
<p>
Beiträge laufen über eine einfache Banküberweisung - kein Zahlungsdienstleister, keine
+2 -2
View File
@@ -25,8 +25,8 @@
<p>
Drizz.li is free to use. If you find it useful, you can support it with a small monthly
contribution (from €3 / month) - it helps cover the domain, hosting and development time. As a
thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, and
new supporter features as they land.
thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, the
seasonal outlook for the months ahead, and new supporter features as they land.
</p>
<p>
Contributions are handled by a simple bank transfer - no payment processor, no stored card
+2 -1
View File
@@ -29,7 +29,8 @@
Drizz.li es gratuito. Si te resulta útil, puedes apoyarlo con una pequeña aportación mensual
(desde 3 € al mes): ayuda a cubrir el dominio, el alojamiento y el tiempo de desarrollo. Como
agradecimiento, quienes colaboran desbloquean los extras: clima histórico con comparación frente
a las normales climáticas y las nuevas funciones que vayan llegando.
a las normales climáticas, la perspectiva estacional de los próximos meses y las nuevas
funciones que vayan llegando.
</p>
<p>
Las aportaciones se gestionan mediante una simple transferencia bancaria: sin pasarela de pago y
+2 -1
View File
@@ -31,7 +31,8 @@
Drizz.li est gratuit. S'il vous est utile, vous pouvez le soutenir par une petite contribution
mensuelle (à partir de 3 € par mois) : elle aide à couvrir le nom de domaine, l'hébergement et
le temps de développement. En remerciement, les contributeurs débloquent les bonus : la météo
historique avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir.
historique avec comparaison aux normales climatiques, l'aperçu saisonnier des mois à venir,
ainsi que les nouvelles fonctions à venir.
</p>
<p>
Les contributions passent par un simple virement bancaire : aucun prestataire de paiement,
+2 -1
View File
@@ -29,7 +29,8 @@
Drizz.li è gratuito. Se lo trovi utile, puoi sostenerlo con un piccolo contributo mensile (da 3
€ al mese): aiuta a coprire dominio, hosting e tempo di sviluppo. Come ringraziamento, chi
sostiene il progetto sblocca gli extra: meteo storico con confronto rispetto alle normali
climatiche e le nuove funzioni man mano che arrivano.
climatiche, le prospettive stagionali per i mesi a venire e le nuove funzioni man mano che
arrivano.
</p>
<p>
I contributi avvengono con un semplice bonifico bancario: nessun gestore di pagamenti, nessun
+95 -54
View File
@@ -133,28 +133,20 @@
@apply bg-background text-foreground;
}
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
page reports its data has arrived. */
.page-fade {
transition: opacity 320ms ease;
}
.page-fade-hidden {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-fade {
transition: none;
}
.page-fade-hidden {
opacity: 1;
}
}
/* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part
in the cross-fade. Everything else keeps its pixels: the `day-switch`
class cancels the root animation, so the strip, header and page chrome do
not so much as flicker while the table, summary and charts swap over. */
in the cross-fade - and nothing else is captured at all. A captured
element is neither painted nor hit-testable in the live page for the
length of the animation (per spec: as if it had visibility:hidden and
pointer-events:none), and the root is captured by default, which captures
the whole page. That is what made the strip unclickable and the page
unscrollable while a switch played - no amount of pointer-events on the
overlay could fix it, because the page underneath was gone too. With the
root left un-named the strip, the chrome and the scroller keep their live
pixels and stay fully interactive. */
:root.day-switch {
view-transition-name: none;
}
:root.day-switch .day-region-table {
view-transition-name: day-table;
}
@@ -165,26 +157,87 @@
view-transition-name: day-charts;
}
/* The sticky day strip never changes during a day switch, but it has to be
captured too: view-transition snapshots all paint in one layer above the
page, so without its own group (and a higher z-index in that layer) the
fading table and charts would slide over the top of it. */
:root.day-switch .daystrip {
view-transition-name: daystrip;
}
::view-transition-group(daystrip) {
z-index: 20;
/* Region snapshots paint in a viewport-fixed layer above the live page, at
their full layout size - including the part of the table normally
scrolled up behind the sticky strip and the topbar. The strip used to be
captured purely to stay on top of that, but a captured strip is dead to
input; instead the whole overlay is clipped at the strip bar's bottom
edge (measured per switch by runDayTransition), so the snapshots stay
out of the chrome and the chrome stays live. */
:root.day-switch::view-transition {
clip-path: inset(var(--day-switch-clip, 0px) 0 0 0);
}
/* Same for the topbar: a captured region's snapshot is painted at its layout
position, including the part normally scrolled up behind the chrome. */
:root.day-switch .topbar {
/* Page swaps capture the topbar and sidebar: a region that is identical on
both sides should not be animated at all, so they are pinned here and
swapped outright below. */
:root.page-switch .topbar {
view-transition-name: topbar;
}
::view-transition-group(topbar) {
z-index: 30;
}
:root.page-switch .sidebar-region {
view-transition-name: sidebar;
}
::view-transition-group(sidebar) {
z-index: 25;
}
@media (prefers-reduced-motion: reduce) {
.day-region-table,
.day-region-summary,
.day-region-charts,
.topbar,
.sidebar-region {
view-transition-name: none;
}
}
/* The snapshot layer is a fixed overlay across the whole viewport and it
hit-tests, so for the length of an animation every click would land on it
and die - a day tapped while the previous switch is still fading simply
went dead. Let input fall through to the live page instead: by the time
the pseudo elements exist the DOM is already in its final state, and a
click that starts a new transition supersedes the running one cleanly
(see view-transition.ts). */
::view-transition {
pointer-events: none;
}
/* ── Cross-fades that do not dip ──────────────────────────────────────────
The default cross-fade is NOT opacity-neutral: coverage of two stacked
layers is `new + old * (1 - new)`, so at the midpoint the pair covers only
~75% of the region and the page background shows through both. That dip is
the flash - over the whole viewport for a page swap, and over the table,
summary and charts (between them the whole content column) for a day
switch.
Two ways out, and which one applies depends on whether the *incoming*
snapshot is opaque. Holding the outgoing one at full opacity fixes the
coverage arithmetic, but a translucent incoming snapshot then reads
straight through it and the old content lingers as a ghost. */
/* The document background is opaque, so the whole-viewport swap can simply
hold the outgoing snapshot until the pseudo elements are torn down. */
@keyframes vt-fade-in {
from {
opacity: 0;
}
}
::view-transition-old(root) {
animation: none;
opacity: 1;
}
::view-transition-new(root) {
animation: vt-fade-in 400ms ease;
}
/* The day regions are not opaque - they are cards with gaps and headings
between them - so they keep a real cross-fade and fix the dip the other
way: `plus-lighter` makes the two halves sum to exactly the original
wherever they agree, which is most of the region (the card backgrounds are
the same on both days; only the readings differ). */
::view-transition-old(day-table),
::view-transition-new(day-table),
::view-transition-old(day-summary),
@@ -193,32 +246,20 @@
::view-transition-new(day-charts) {
animation-duration: 420ms;
animation-timing-function: ease;
mix-blend-mode: plus-lighter;
}
:root.day-switch::view-transition-old(root),
:root.day-switch::view-transition-new(root) {
/* Pinned chrome swaps outright: identical on both sides, so any animation
would only risk a flicker. */
::view-transition-old(topbar),
::view-transition-old(sidebar) {
animation: none;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.day-region-table,
.day-region-summary,
.day-region-charts,
.daystrip,
.topbar {
view-transition-name: none;
}
}
/* Theme changes cross-fade the whole document in one pass (see
routes/+layout.svelte). Doing it as a view transition instead of a blanket
`* { transition }` matters: that blanket rule also stretched every
component's own hover and focus transitions to 400ms for the duration of
the switch, which read as lag on interactive controls. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 400ms;
animation-timing-function: ease;
::view-transition-new(topbar),
::view-transition-new(sidebar) {
animation: none;
opacity: 1;
}
/* Fallback for browsers without view transitions: fade the colours only. */
+3 -2
View File
@@ -15,8 +15,9 @@
<h2>1. Was Sie erhalten</h2>
<p>
Ein Unterstützerbeitrag schaltet die Extras für den bezahlten Zeitraum frei: derzeit
historisches Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie
erscheinen. Die kostenlosen Teile von Drizz.li bleiben für alle frei.
historisches Wetter mit Vergleich zu den Klimanormalen und die saisonalen Aussichten für die
kommenden Monate sowie neue Funktionen, sobald sie erscheinen. Die kostenlosen Teile von
Drizz.li bleiben für alle frei.
</p>
<h2>2. Ablauf</h2>
+2 -2
View File
@@ -14,8 +14,8 @@
<h2>1. What you get</h2>
<p>
A supporter contribution unlocks the supporter extras for the paid period: currently historical
weather with climate-normal comparisons, plus new supporter features as they land. The free
parts of Drizz.li stay free for everyone.
weather with climate-normal comparisons and the seasonal outlook for the months ahead, plus new
supporter features as they land. The free parts of Drizz.li stay free for everyone.
</p>
<h2>2. How it works</h2>
+3 -2
View File
@@ -17,8 +17,9 @@
<h2>1. Qué obtienes</h2>
<p>
Una aportación desbloquea los extras durante el periodo pagado: actualmente el clima histórico
con comparación frente a las normales climáticas, además de las nuevas funciones que vayan
llegando. Las partes gratuitas de Drizz.li siguen siendo gratuitas para todos.
con comparación frente a las normales climáticas y la perspectiva estacional de los próximos
meses, además de las nuevas funciones que vayan llegando. Las partes gratuitas de Drizz.li
siguen siendo gratuitas para todos.
</p>
<h2>2. Cómo funciona</h2>
+2 -2
View File
@@ -14,8 +14,8 @@
<h2>1. Ce que vous obtenez</h2>
<p>
Une contribution débloque les bonus pour la période payée : actuellement la météo historique
avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir. Les
parties gratuites de Drizz.li le restent pour tout le monde.
avec comparaison aux normales climatiques et l'aperçu saisonnier des mois à venir, ainsi que les
nouvelles fonctions à venir. Les parties gratuites de Drizz.li le restent pour tout le monde.
</p>
<h2>2. Fonctionnement</h2>
+3 -2
View File
@@ -14,8 +14,9 @@
<h2>1. Cosa ottieni</h2>
<p>
Un contributo sblocca gli extra per il periodo pagato: attualmente il meteo storico con
confronto rispetto alle normali climatiche, oltre alle nuove funzioni man mano che arrivano. Le
parti gratuite di Drizz.li restano gratuite per tutti.
confronto rispetto alle normali climatiche e le prospettive stagionali per i mesi a venire,
oltre alle nuove funzioni man mano che arrivano. Le parti gratuite di Drizz.li restano gratuite
per tutti.
</p>
<h2>2. Come funziona</h2>
+19 -6
View File
@@ -42,6 +42,21 @@
let subtitle = $derived(
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
);
// Joined here rather than in the markup: Svelte trims the whitespace around a
// line break, so a separator written as "{admin1},\n{country}" renders as
// "Canton of Schwyz,Switzerland". Elevation joins the same line; from lg up
// the whole line is hidden, because the topbar pill carries the region and
// elevation there.
let region = $derived(
[
location?.admin1,
location?.country,
location?.elevation != null ? `${Math.round(location.elevation)}m` : null
]
.filter(Boolean)
.join(', ')
);
</script>
{#if subtitle && location}
@@ -55,13 +70,11 @@
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
<span class="font-medium text-muted-foreground">· {subtitle}</span>
</h1>
<p class="truncate text-sm text-muted-foreground">
<span class="lg:hidden"
>{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>{subtitle}
</p>
{#if region}
<p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
{/if}
</div>
</div>
@@ -1,13 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { syncSearchParams } from '$lib/utils/url-state';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
@@ -44,7 +46,7 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedData != null);
reportPageReady(() => fetchedData != null || loadError != null);
useHeroActions(heroActions);
@@ -56,10 +58,12 @@
storedLocation.set(data.location);
});
const DEFAULT_MODEL = 'ncep_gefs_seamless';
let params = $state({
...defaultParameters,
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
models: ['ncep_gefs_seamless']
models: [DEFAULT_MODEL]
});
// units live in a persisted store; mirror them into params so a change
@@ -94,7 +98,7 @@
$effect(() => {
const model = params.models?.[0];
if (!mounted || !model) return;
syncSearchParams(get(page).url, { model });
syncSearchParams({ model: unlessDefault(model, DEFAULT_MODEL) });
});
// components persist across refetches; entries are null while unmounted
@@ -294,9 +298,9 @@
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
<div class="flex w-full items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
selectedModel={params.models?.[0] ?? DEFAULT_MODEL}
groups={ensembleModelGroups}
label={m.model_ensemble()}
onModelChange={(model) => {
@@ -340,6 +344,8 @@
</div>
{/if}
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<div class="relative">
{#if fetchedData}
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
the page margins (padded), the graphs bleed to the edges -->
@@ -374,8 +380,16 @@
</div>
{:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
<div in:fade={{ duration: 200 }} out:skeletonOut>
<ChartContainer
loading
chartCount={params.hourly?.length || 1}
chartHeight={340}
bleed={false}
/>
</div>
{/if}
</div>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
@@ -1,6 +1,7 @@
<script lang="ts">
import { onDestroy, onMount, tick, untrack } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
@@ -8,14 +9,16 @@
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
import { readList, syncSearchParams } from '$lib/utils/url-state';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { listUnlessDefault, readList, syncSearchParams } from '$lib/utils/url-state';
import {
ChartContainer,
ChartToolbar,
type ExportableChart,
type ExportLegendItem
type ExportLegendItem,
type ExportableChart
} from '$lib/components/charts';
import {
CHART_COLORS,
CanvasChart,
@@ -54,7 +57,9 @@
const MODEL_IDS = new Set(modelOptions.map((model) => model.value));
const MODEL_ORDER = new Map(modelOptions.map((model, index) => [model.value, index]));
const AUTOMATIC_SEAMLESS_MODEL_IDS = new Set(
modelGroups.find((group) => group.value === 'automatic_seamless')?.models.map((model) => model.value)
modelGroups
.find((group) => group.value === 'automatic_seamless')
?.models.map((model) => model.value)
);
const COMPARE_MODEL_GROUPS = [
...modelGroups.filter((group) => group.value !== 'automatic_seamless'),
@@ -81,6 +86,10 @@
];
let { data }: { data: PageData } = $props();
// the URL is the source of truth: location comes from the load function,
// which is also correct on hydrated prerendered pages. The persisted store
// only mirrors it so the header and bare /weather/* redirects follow along.
let location = $derived(data.location);
let mounted = $state(false);
let loading = $state(true);
@@ -100,16 +109,17 @@
showModelNames ? (isMobile ? 92 : 116) : isMobile ? 34 : 40
);
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
let params = $state({
...defaultParameters,
hourly: [...STANDARD_COMPARE_VARIABLES],
models: orderModels([
const DEFAULT_MODELS = orderModels([
'ecmwf_ifs',
'meteofrance_arpege_world',
'ukmo_global_deterministic_10km',
'icon_global',
'gfs_global'
])
]);
let params = $state({
...defaultParameters,
hourly: [...STANDARD_COMPARE_VARIABLES],
models: [...DEFAULT_MODELS]
});
let appliedHourly = $state<string[]>([...STANDARD_COMPARE_VARIABLES]);
let appliedModels = $state<string[]>([...params.models]);
@@ -226,9 +236,11 @@
const models = appliedModels;
const vars = appliedHourly;
if (!mounted) return;
syncSearchParams(get(page).url, {
models: models.length ? models.join(',') : null,
vars: vars.length ? vars.join(',') : null
// variables are always held in the canonical order, so compare the ordered
// list against the ordered defaults - only a real change reaches the URL
syncSearchParams({
models: listUnlessDefault(models, DEFAULT_MODELS),
vars: listUnlessDefault(orderVariables(vars), orderVariables(STANDARD_COMPARE_VARIABLES))
});
});
@@ -507,15 +519,12 @@
});
let exportLegend = $derived.by((): ExportLegendItem[] => [
...displayedModels
.map((model) => ({
...displayedModels.map((model) => ({
name: modelLabel(model.modelId),
color: modelColor(model.modelId, fetchedData?.selection.models),
style: 'point' as const
})),
...(chartDefs.some((def) =>
def.series.some((series) => series.name === m.compare_model_mean())
)
...(chartDefs.some((def) => def.series.some((series) => series.name === m.compare_model_mean()))
? [{ name: m.compare_model_mean(), color: CHART_COLORS.average, style: 'dashed' as const }]
: [])
]);
@@ -657,6 +666,8 @@
</div>
{/if}
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<div class="relative">
{#if selectionEmpty}
<div
class="rounded-xl border border-dashed border-border bg-muted/30 px-4 py-10 text-center text-sm text-muted-foreground"
@@ -740,13 +751,18 @@
{/if}
</div>
{:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<div in:fade={{ duration: 200 }} out:skeletonOut>
<ChartContainer
loading
chartCount={appliedHourly.filter((v) => v !== 'weather_code' && v !== 'cloud_cover').length || 1}
chartCount={appliedHourly.filter((v) => v !== 'weather_code' && v !== 'cloud_cover')
.length || 1}
chartHeight={300}
bleed={false}
/>
</div>
{/if}
</div>
{#if fetchedData && !loading && fetchedData.selection.hourly.includes('weather_code')}
<div
@@ -1,6 +1,8 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { formatZoned } from '$lib/utils/date';
import {
groupHover,
groupRange,
@@ -9,7 +11,6 @@
setGroupRange
} from '$lib/charts';
import * as m from '$lib/paraglide/messages';
import { formatZoned } from '$lib/utils/date';
import { getWeatherIconName, hasWeatherIcon } from '../../utils/weather-codes';
import { modelColor, modelLabel } from './comparison';
@@ -140,10 +141,7 @@
const span = viewEnd - viewStart;
const nextSpan = span * factor;
const fraction = (centerTime - viewStart) / Math.max(1, span);
applyRange(
centerTime - fraction * nextSpan,
centerTime + (1 - fraction) * nextSpan
);
applyRange(centerTime - fraction * nextSpan, centerTime + (1 - fraction) * nextSpan);
}
function nearestIndex(values: number[], target: number): number {
@@ -154,10 +152,7 @@
if (values[middle] < target) low = middle + 1;
else high = middle;
}
if (
low > 0 &&
Math.abs(values[low - 1] - target) <= Math.abs(values[low] - target)
) {
if (low > 0 && Math.abs(values[low - 1] - target) <= Math.abs(values[low] - target)) {
return low - 1;
}
return low;
@@ -402,8 +397,7 @@
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);
((panStart.x - event.clientX) / Math.max(1, plotWidth)) * (panStart.end - panStart.start);
applyRange(panStart.start + deltaTime, panStart.end + deltaTime);
return;
}
@@ -762,9 +756,7 @@
</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 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">
@@ -13,17 +13,19 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type HistoricalForecastResult,
fetchClimateNormals,
fetchHistoricalWeather
} from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte';
import { archiveModelGroups, defaultParameters } from '../../options';
@@ -39,8 +41,10 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
// The page cross-fade waits for this before revealing the new page - and so
// does the loading overlay, so a visitor without a key has to count as ready:
// the locked panel is the finished page here, nothing is on its way.
reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -213,13 +217,15 @@
);
function switchDay(date: Date) {
selectedDay.setTime(date.getTime());
// see the week page: an unformattable selected day breaks every consumer
const time = date?.getTime();
if (Number.isFinite(time)) selectedDay.setTime(time);
}
</script>
<!-- the reanalysis picker rides in the layout's location row -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={archiveModel}
groups={archiveModelGroups}
@@ -237,7 +243,7 @@
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head>
<PaywallGate feature={m.page_historical_subtitle()}>
<SupporterGate feature={m.page_historical_subtitle()}>
<DateRangeControls
start={startDate}
end={endDate}
@@ -254,7 +260,7 @@
</div>
{/if}
<div class="mt-4">
<div class="relative mt-4">
{#if result && fetchedHourly && fetchedDaily}
<HistoricalDaily
daily={result.daily}
@@ -278,10 +284,10 @@
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
{:else}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
</div>
{/if}
</div>
</PaywallGate>
</SupporterGate>
@@ -9,7 +9,7 @@
} from '$lib/services/weather';
import { getColor, getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
interface Props {
@@ -202,7 +202,8 @@
>{formatZoned(date, timezone, 'd MMM')}</span
>
<svg class="my-0.5" width="20" height="20" aria-hidden="true">
<svg class="my-0.5" width="20" height="20">
<title>{getWeatherDescription(daily.weather_code[i])}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
daily.weather_code[i],
@@ -31,7 +31,7 @@
let chartComponents: (CanvasChart | null)[] = $state([]);
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null));
// Same customizable layout as the 7-day meteograms, so the two pages match.
// Same customizable layout as the week-page meteograms, so the two pages match.
let panels = $derived(
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
);
+35 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { mapTransitionCover, reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
import { mapsDomainForModel } from '$lib/utils/maps-domain';
@@ -16,6 +18,23 @@
let hashOverride = $state<string | null>(null);
let iframeEl = $state<HTMLIFrameElement | null>(null);
let mapReady = $state(false);
let frameLoaded = $state(false);
// The map is a cross-origin iframe: until it has loaded there is nothing here
// but an empty panel, and a page transition that ends before then cross-fades
// the old page into that blank. Reporting readiness holds the transition (and
// then the loading overlay) until there is actually a map to fade into.
// `om-maps:ready` is the good signal; the iframe's own load event is the
// backstop, so a map that fails to boot still releases the page.
reportPageReady(() => mapReady || frameLoaded);
// The map stays under an opaque cover until it is ready, so it fades up from
// a clean panel instead of flashing the iframe's white boot document (worst
// in dark mode). The same cover is what makes navigation transitions work at
// all here: a cross-origin iframe is never painted into a view transition
// snapshot, so the cover is raised again just before a departure is captured
// (see mapTransitionCover) - both snapshots then hold real pixels.
let revealed = $derived(mapReady || frameLoaded);
const postToMap = (message: Record<string, unknown>) => {
iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN);
@@ -83,7 +102,7 @@
<!-- Full-bleed map: the layout drops its padding for this route. The map
follows our theme through the color-scheme declared on :root/.dark -->
<div class="h-full w-full bg-background">
<div class="relative h-full w-full bg-background">
<!-- allow="cross-origin-isolated" delegates SharedArrayBuffer use to the
map; it only takes effect when this site itself is served with
COOP/COEP headers (see README, Deployment) -->
@@ -95,7 +114,22 @@
allowfullscreen
allow="cross-origin-isolated"
referrerpolicy="no-referrer"
onload={() => (frameLoaded = true)}
class="block h-full w-full border-0"
class:invisible={$mapTransitionCover}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe>
<!-- No `in:` transition on purpose: when the cover comes back for a
departure it has to be at full opacity by the time the snapshot is
taken, not fading towards it. The iframe is additionally made
invisible then (above), so the capture never has to paint cross-origin
content at all - some engines degrade the whole transition over it. -->
{#if !revealed || $mapTransitionCover}
<div
class="absolute inset-0 bg-background"
out:fade={{ duration: 300 }}
aria-hidden="true"
></div>
{/if}
</div>
@@ -6,19 +6,21 @@
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type SeasonalForecastResult,
fetchClimateNormals,
fetchSeasonalForecast
} from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, seasonalModelGroups } from '../../options';
@@ -33,8 +35,10 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => result != null);
// The page cross-fade waits for this before revealing the new page - and so
// does the loading overlay, so a visitor without a key has to count as ready:
// the locked panel is the finished page here, nothing is on its way.
reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -189,16 +193,27 @@
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<!-- Out of flow on lg+ (the hero row is `relative`), same as the week, 14-day
and archive pages: the controls then cannot move the heading when they
change size. -->
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
{#if result}
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
<!-- Range buttons reslice the already-fetched horizon (no refetch). While a
forecast is on its way they stay mounted but invisible, because
mounting them on arrival re-flowed the row and nudged the heading.
Two exceptions, both of which would reserve a gap for something that
is never coming: below sm the group is a full-width row of its own,
and behind the supporter gate there is no forecast on its way at all. -->
{#if $isSupporter}
<div
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto {result
? ''
: 'hidden sm:flex sm:invisible'}"
role="group"
aria-label={m.seasonal_range_aria()}
aria-hidden={!result}
>
{#each RANGES as range, i (range.label)}
{@const disabled = range.days !== Infinity && range.days > horizonDays}
{@const disabled = !result || (range.days !== Infinity && range.days > horizonDays)}
<button
type="button"
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
@@ -209,6 +224,7 @@
: ''}"
aria-pressed={rangeIndex === i}
{disabled}
tabindex={result ? undefined : -1}
onclick={() => (rangeIndex = i)}
>
{range.label}
@@ -253,7 +269,7 @@
</p>
</div>
<PaywallGate feature={m.page_seasonal_subtitle()}>
<SupporterGate feature={m.page_seasonal_subtitle()}>
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
@@ -262,6 +278,9 @@
</div>
{/if}
<!-- `relative` anchors the placeholder while it fades out over the real
outlook rather than holding its own slot in the layout (skeletonOut). -->
<div class="relative">
{#if visible && months.length > 0}
<SeasonalMonths {months} units={params} {normals} />
@@ -289,7 +308,7 @@
</ChartToolbar>
</div>
{:else if !loadError}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
@@ -298,4 +317,5 @@
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
</div>
{/if}
</PaywallGate>
</div>
</SupporterGate>
@@ -0,0 +1,129 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
computeDayNightWeatherCodes,
getWeatherDescription,
getWeatherIconName
} from './weather-codes';
const ICON_DIR = join(process.cwd(), 'static/images/weather-icons');
// The codes open-meteo actually emits (WMO 4677 subset), with the family each
// one belongs to. The icon mapping used to be built for a different code table,
// which is how fog ended up showing hail and a hail thunderstorm a tornado -
// mismatches nothing in the type system could catch.
const OPEN_METEO_CODES: Record<number, { meaning: string; family: RegExp }> = {
0: { meaning: 'Clear sky', family: /clear/ },
1: { meaning: 'Mainly clear', family: /clear|cloudy/ },
2: { meaning: 'Partly cloudy', family: /cloudy/ },
3: { meaning: 'Overcast', family: /cloudy/ },
45: { meaning: 'Fog', family: /fog/ },
48: { meaning: 'Depositing rime fog', family: /fog/ },
51: { meaning: 'Light drizzle', family: /sprinkle|showers/ },
53: { meaning: 'Moderate drizzle', family: /sprinkle|rain/ },
55: { meaning: 'Dense drizzle', family: /sprinkle|rain/ },
56: { meaning: 'Light freezing drizzle', family: /rain-mix|sleet/ },
57: { meaning: 'Dense freezing drizzle', family: /rain-mix|sleet/ },
61: { meaning: 'Slight rain', family: /rain|sprinkle|showers/ },
63: { meaning: 'Moderate rain', family: /rain/ },
65: { meaning: 'Heavy rain', family: /rain/ },
66: { meaning: 'Light freezing rain', family: /rain-mix|sleet/ },
67: { meaning: 'Heavy freezing rain', family: /rain-mix|sleet/ },
71: { meaning: 'Slight snowfall', family: /snow/ },
73: { meaning: 'Moderate snowfall', family: /snow/ },
75: { meaning: 'Heavy snowfall', family: /snow/ },
77: { meaning: 'Snow grains', family: /snow/ },
80: { meaning: 'Slight rain showers', family: /showers|rain/ },
81: { meaning: 'Moderate rain showers', family: /showers|rain/ },
82: { meaning: 'Violent rain showers', family: /showers|rain/ },
85: { meaning: 'Slight snow showers', family: /snow/ },
86: { meaning: 'Heavy snow showers', family: /snow/ },
95: { meaning: 'Thunderstorm', family: /thunderstorm|storm-showers|lightning/ },
96: { meaning: 'Thunderstorm with slight hail', family: /thunderstorm|storm-showers|hail/ },
99: { meaning: 'Thunderstorm with heavy hail', family: /thunderstorm|storm-showers|hail/ }
};
const codes = Object.keys(OPEN_METEO_CODES).map(Number);
describe('getWeatherIconName', () => {
it.each(codes)('code %i resolves to icon files that exist', (code) => {
for (const daytime of [true, false]) {
const name = getWeatherIconName(code, daytime);
expect(existsSync(join(ICON_DIR, `${name}.svg`)), `${name}.svg missing`).toBe(true);
}
});
it.each(codes)('code %i uses a glyph that matches its meaning', (code) => {
const { meaning, family } = OPEN_METEO_CODES[code];
for (const daytime of [true, false]) {
const name = getWeatherIconName(code, daytime);
expect(name, `${code} (${meaning}) → ${name}`).toMatch(family);
}
});
it('never shows a tornado: open-meteo has no tornado code', () => {
const names = codes.flatMap((c) => [getWeatherIconName(c, true), getWeatherIconName(c, false)]);
expect(names.some((n) => n.includes('tornado'))).toBe(false);
});
it('distinguishes overcast from partly cloudy', () => {
expect(getWeatherIconName(3, true)).not.toBe(getWeatherIconName(2, true));
});
it('falls back to a clear glyph for codes it does not know', () => {
expect(getWeatherIconName(12345, true)).toBe('wi-day-clear');
});
});
describe('getWeatherDescription', () => {
it.each(codes)('code %i has a description', (code) => {
expect(getWeatherDescription(code).length).toBeGreaterThan(0);
});
it('returns nothing for unknown or missing codes', () => {
expect(getWeatherDescription(12345)).toBe('');
expect(getWeatherDescription(null)).toBe('');
expect(getWeatherDescription(undefined)).toBe('');
expect(getWeatherDescription(NaN)).toBe('');
});
});
describe('daily aggregation', () => {
const DAY = 24 * 3600;
// One synthetic day: sunrise 06:00, sunset 20:00, hourly codes from 00:00.
const hourly = (codes: number[]) => codes.map((_, i) => i * 3600 * 1000);
const run = (hourCodes: number[]) =>
computeDayNightWeatherCodes(hourly(hourCodes), hourCodes, [6 * 3600], [20 * 3600]).day[0];
const mostlyClear = (overrides: Record<number, number>) =>
Array.from({ length: DAY / 3600 }, (_, h) => overrides[h] ?? 0);
it('lets a single thundery hour lead the day, as before', () => {
expect(run(mostlyClear({ 18: 95 }))).toBe(95);
});
it('picks the plain thunderstorm when a lone 99 ties with a 95', () => {
// the reported case: one hour of 99 next to one hour of 95 used to escalate
// the whole day card to the most extreme code on the scale
expect(run(mostlyClear({ 18: 99, 19: 95 }))).toBe(95);
});
it('still shows 99 when it is the only thunder code', () => {
expect(run(mostlyClear({ 18: 99 }))).toBe(99);
});
it('follows frequency before severity within thunder', () => {
expect(run(mostlyClear({ 15: 99, 16: 99, 17: 95 }))).toBe(99);
});
it('keeps preferring the heavier code on a tie outside thunder', () => {
// 65 (heavy rain) over 80 (slight showers) - the open-meteo max-code flaw
expect(run(mostlyClear({ 12: 65, 13: 80 }))).toBe(65);
});
it('does not let one foggy hour brand the day', () => {
expect(run(mostlyClear({ 7: 45 }))).toBe(0);
});
});
+83 -23
View File
@@ -1,8 +1,10 @@
import * as m from '$lib/paraglide/messages';
const weatherCodes: Record<number, string> = {
0: 'clear',
1: 'clear',
2: 'cloudy',
3: 'cloudy',
3: 'overcast',
4: 'fog',
5: 'fog',
10: 'fog',
@@ -30,26 +32,26 @@ const weatherCodes: Record<number, string> = {
42: 'rain',
43: 'sprinkle',
44: 'rain',
45: 'hail',
45: 'fog',
46: 'hail',
47: 'snow',
48: 'snow',
48: 'fog',
50: 'sprinkle',
51: 'sprinkle',
52: 'rain',
53: 'rain',
53: 'sprinkle',
54: 'sprinkle',
55: 'rain',
56: 'rain-mix',
57: 'sprinkle',
57: 'rain-mix',
58: 'rain',
60: 'sprinkle',
61: 'sprinkle',
62: 'rain',
63: 'rain',
64: 'hail',
65: 'hail',
66: 'hail',
65: 'rain',
66: 'rain-mix',
67: 'rain-mix',
68: 'rain-mix',
70: 'snow',
@@ -61,13 +63,13 @@ const weatherCodes: Record<number, string> = {
76: 'snow',
77: 'snow',
78: 'snow',
80: 'rain',
81: 'sprinkle',
80: 'showers',
81: 'showers',
82: 'rain',
83: 'rain',
84: 'storm-showers',
85: 'rain-mix',
86: 'rain-mix',
85: 'snow',
86: 'snow',
87: 'rain-mix',
89: 'hail',
90: 'lightning',
@@ -77,11 +79,19 @@ const weatherCodes: Record<number, string> = {
94: 'lightning',
95: 'thunderstorm',
96: 'thunderstorm',
99: 'tornado'
99: 'storm-showers'
};
// These conditions ship only as a single neutral glyph (no day/night variant).
const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']);
// Conditions that ship as a single neutral glyph (no day/night variant). The
// file is not always wi-<name>: 'overcast' uses the flat cloud, which keeps it
// distinct from 'cloudy' (code 2), whose glyph carries a sun or moon.
const NEUTRAL_ICONS: Record<string, string> = {
'snowflake-cold': 'wi-snowflake-cold',
'strong-wind': 'wi-strong-wind',
dust: 'wi-dust',
tornado: 'wi-tornado',
overcast: 'wi-cloudy'
};
export function hasWeatherIcon(code: unknown): code is number {
return (
@@ -93,10 +103,52 @@ export function hasWeatherIcon(code: unknown): code is number {
export function getWeatherIconName(code: number, daytime: boolean): string {
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`;
const neutral = NEUTRAL_ICONS[name];
if (neutral) return neutral;
return `wi-${daytime ? 'day' : 'night'}-${name}`;
}
// Plain-language name for each code open-meteo actually emits (WMO 4677 subset),
// used as the hover title on the pictograms. A glyph alone is ambiguous - the
// hail-thunderstorm swirl in particular reads as something far more dramatic
// than "thunderstorm with heavy hail".
const WMO_DESCRIPTIONS: Record<number, () => string> = {
0: m.wmo_0,
1: m.wmo_1,
2: m.wmo_2,
3: m.wmo_3,
45: m.wmo_45,
48: m.wmo_48,
51: m.wmo_51,
53: m.wmo_53,
55: m.wmo_55,
56: m.wmo_56,
57: m.wmo_57,
61: m.wmo_61,
63: m.wmo_63,
65: m.wmo_65,
66: m.wmo_66,
67: m.wmo_67,
71: m.wmo_71,
73: m.wmo_73,
75: m.wmo_75,
77: m.wmo_77,
80: m.wmo_80,
81: m.wmo_81,
82: m.wmo_82,
85: m.wmo_85,
86: m.wmo_86,
95: m.wmo_95,
96: m.wmo_96,
99: m.wmo_99
};
/** Localized condition text for a weather code; '' for codes we have no name for. */
export function getWeatherDescription(code: number | null | undefined): string {
if (code == null || !Number.isFinite(code)) return '';
return WMO_DESCRIPTIONS[code]?.() ?? '';
}
// ─── Local day/night weather codes ──────────────────────────────────────────
// Open-meteo's daily weather_code is a plain numeric max over all 24 hourly
// codes (VariableDaily.swift: `.max(.weathercode)`) — there is no day/night
@@ -121,8 +173,17 @@ const FOG = new Set([45, 48]);
// counterparts when breaking frequency ties (fixes 80 "outranking" 65).
const INTENSITY_EQUIV: Record<number, number> = { 80: 61, 81: 63, 82: 65, 77: 71, 85: 71, 86: 75 };
/** Most frequent code; ties go to the more intense (then higher) code. */
function modeWithHighTiebreak(codes: number[]): number {
/**
* Most frequent code; ties go to the more intense (then higher) code, or with
* `preferLower` to the least intense one.
*
* Thunder is the one group that ties downwards. Within it the code only
* describes how much hail comes with the storm, and letting the worst of them
* win a coin-flip tie is how a single hour of 99 used to brand a whole day as
* the most extreme thing on the scale. Elsewhere the heavier code winning a tie
* is the point (heavy rain over slight showers).
*/
function modeWithHighTiebreak(codes: number[], preferLower = false): number {
const counts = new Map<number, number>();
for (const c of codes) counts.set(c, (counts.get(c) ?? 0) + 1);
let best = codes[0];
@@ -130,11 +191,10 @@ function modeWithHighTiebreak(codes: number[]): number {
for (const [code, count] of counts) {
const intensity = INTENSITY_EQUIV[code] ?? code;
const bestIntensity = INTENSITY_EQUIV[best] ?? best;
if (
count > bestCount ||
(count === bestCount &&
(intensity > bestIntensity || (intensity === bestIntensity && code > best)))
) {
const winsTie = preferLower
? intensity < bestIntensity || (intensity === bestIntensity && code < best)
: intensity > bestIntensity || (intensity === bestIntensity && code > best);
if (count > bestCount || (count === bestCount && winsTie)) {
best = code;
bestCount = count;
}
@@ -152,7 +212,7 @@ function daypartCode(codes: number[]): number | null {
// (open-meteo's "don't hide hazards" philosophy, kept per-group).
for (const group of [THUNDER, FREEZING, SNOW, RAIN, DRIZZLE]) {
const hits = hours.filter((c) => group.has(c));
if (hits.length > 0) return modeWithHighTiebreak(hits);
if (hits.length > 0) return modeWithHighTiebreak(hits, group === THUNDER);
}
// Fog needs persistence (≥2h and ≥¼ of the daypart) so one misty hour at
+70 -23
View File
@@ -18,7 +18,8 @@
import { formatZoned } from '$lib/utils/date';
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location';
import { syncSearchParams } from '$lib/utils/url-state';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer } from '$lib/components/charts';
@@ -49,7 +50,7 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
reportPageReady(() => fetchedDaily != null && fetchedHourly != null);
reportPageReady(() => (fetchedDaily != null && fetchedHourly != null) || loadError != null);
useHeroActions(heroActions);
@@ -85,10 +86,21 @@
// arrives (no layout shift)
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
// The hourly table is a header row plus one row per enabled variable, so the
// placeholder below reserves exactly that and nothing under it jumps when the
// real table arrives.
const TABLE_ROW_PX = 57;
// The hourly table is a header row, the time/daylight row, then one row per
// enabled variable, so the placeholder below reserves exactly that and
// nothing under it jumps when the real table arrives. Rows are shorter below
// lg, where the cells sit tighter - measured, not guessed: 36px against 55px,
// and the time row is 48px at every width.
let compactRows = $state(false);
onMount(() => {
const mq = window.matchMedia('(max-width: 1023px)');
const apply = () => (compactRows = mq.matches);
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
let tableRowPx = $derived(compactRows ? 36 : 55);
const TABLE_TIME_ROW_PX = 48;
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
// Request only the hourly variables the table rows and meteograms actually
@@ -200,8 +212,13 @@
// Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
// A model that answers with a broken timestamp must not be able to park an
// unreadable date in `selectedDay`: everything downstream formats it, and a
// date that cannot be formatted takes the page with it.
const switchDay = (date: Date) => {
runDayTransition(() => selectedDay.setTime(date.getTime()));
const time = date?.getTime();
if (!Number.isFinite(time)) return;
runDayTransition(() => selectedDay.setTime(time));
};
onMount(() => {
@@ -256,9 +273,9 @@
const isToday = fetchedDaily
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
: false;
syncSearchParams(get(page).url, {
syncSearchParams({
day: isToday ? null : dayKey,
model: model && model !== 'best_match' ? model : null
model: unlessDefault(model, 'best_match')
});
});
@@ -329,12 +346,12 @@
<svelte:head>
<title>Drizz.li | {m.page_week_title()}</title>
<link rel="canonical" href="https://drizz.li/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
<meta name="description" content="Weekly weather forecast with detailed hourly data" />
</svelte:head>
<!-- the model picker rides in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
@@ -457,6 +474,9 @@
{locationRoute}
/>
<!-- `relative` is what lets the placeholder fade out on top of the table
instead of holding a second slot in the layout (see skeletonOut). -->
<div class="relative">
{#if fetchedHourly && fetchedDaily}
<div class="day-region-table" use:daySwap={selectedDayKey}>
<HourlyTable
@@ -470,12 +490,10 @@
</div>
{:else}
<!-- Mirrors the real table: same header bar and the same body height,
so the heading doesn't pop in and nothing below moves.
Placeholders only fade IN - a fade-out would keep them in the
layout while the real content mounts below, and everything below
would jump the moment they finally unmount. -->
so the heading doesn't pop in and nothing below moves. -->
<div
in:fade={{ duration: 200 }}
out:skeletonOut
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
>
<div
@@ -490,8 +508,13 @@
<!-- one placeholder per row the real table will render, so the body
reads as a loading table rather than a blank panel -->
<div class="divide-y divide-border/50">
<!-- the time + daylight row, which is taller than the variable rows -->
<div class="flex items-center gap-4 px-4" style="height: {TABLE_TIME_ROW_PX}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div>
{#each { length: enabledTableRows } as _, i (i)}
<div class="flex items-center gap-4 px-4" style="height: {TABLE_ROW_PX}px">
<div class="flex items-center gap-4 px-4" style="height: {tableRowPx}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div>
@@ -499,19 +522,29 @@
</div>
</div>
{/if}
</div>
<div class="relative">
{#if fetchedHourly && fetchedDaily}
<div class="day-region-summary" use:daySwap={selectedDayKey}>
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
</div>
{:else}
<!-- same footprint as the written forecast, so it doesn't shove the
meteograms down when it arrives -->
<section class="mt-6" in:fade={{ duration: 200 }}>
<div class="h-52 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-40"></div>
<!-- Same footprint as the written forecast, so it doesn't shove the
meteograms down when it arrives. The heights are measured from the
real card at each breakpoint: on phones the narrative is clamped to
five lines with a fixed toggle row, so that side is exact; from md
up the text runs free and this is the typical height. The sun/moon
grid reflows at sm, md and lg, which is why all four are needed. -->
<section class="mt-6" in:fade={{ duration: 200 }} out:skeletonOut>
<div
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
></div>
</section>
{/if}
</div>
<div class="relative">
{#if fetchedHourly}
<div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts
@@ -525,17 +558,31 @@
{:else}
<!-- reserve the exact chart area height before the first fetch resolves,
header row included -->
<section class="mt-8" in:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<section class="mt-8" in:fade={{ duration: 200 }} out:skeletonOut>
<!-- The real header wraps to two rows until the controls fit beside
the title, which happens at different widths than you would
expect because the sidebar takes its share from md up. These
min-heights follow the measured wrap points. -->
<div
class="mb-3 flex min-h-27 flex-wrap items-center justify-between gap-2 sm:min-h-16.5 md:min-h-27 lg:min-h-16.5 xl:min-h-7.5"
>
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
<div class="flex items-center gap-3">
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
</div>
</div>
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
<!-- Each meteogram is its plot plus a title row and axis labels, so
the reserved box needs that chrome on top of the plot height or
everything below lands ~50px per chart too high. -->
<ChartContainer
loading
chartCount={enabledChartCount || 1}
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
/>
</section>
{/if}
</div>
{#if selectedDayKey}
<NearbyCities
@@ -6,7 +6,7 @@
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
@@ -196,6 +196,7 @@
height="100px"
style="filter: url(#thin-day-icon)"
>
<title>{getWeatherDescription(dayCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode,
@@ -208,6 +209,7 @@
width="42px"
height="42px"
>
<title>{getWeatherDescription(nightCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode,
@@ -7,7 +7,7 @@
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import {
getSunshineColor,
getSunshinePercent,
@@ -276,6 +276,7 @@
<div class="icon-row flex w-full items-center justify-center">
<div class="icon-wrap">
<svg class="day-icon fill-foreground">
<title>{getWeatherDescription(dayCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode,
@@ -285,6 +286,7 @@
</svg>
</div>
<svg class="night-icon self-end fill-foreground/60">
<title>{getWeatherDescription(nightCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode,
@@ -96,6 +96,35 @@
return `M 0 ${-R} A ${R} ${R} 0 0 ${outerSweep} 0 ${R} A ${rx} ${R} 0 0 ${innerSweep} 0 ${-R} Z`;
});
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
// ─── Narrative clamp (phones only) ──────────────────────────────────────────
// The narrative runs anywhere from two to nine lines depending on the day,
// which on a phone means the whole page below it moves as soon as the data
// lands. Below md the text is clamped to five lines and gets a toggle, and
// the block reserves those five lines plus the toggle row whatever the
// length - so the card is the same height before and after the fetch, and the
// skeleton can match it exactly. From md up nothing is clamped.
let expanded = $state(false);
let textEl = $state<HTMLParagraphElement>();
let overflows = $state(false);
$effect(() => {
sentences; // re-measure when the day (and so the text) changes
const el = textEl;
if (!el || expanded) return; // measuring while open would always say "fits"
const measure = () => {
// only clamped below md, where the toggle is the only way to see the rest
const clamped = window.matchMedia('(max-width: 767px)').matches;
overflows = clamped && el.scrollHeight - el.clientHeight > 1;
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
});
</script>
<section class="mt-6" aria-label={m.summary_heading()}>
@@ -115,8 +144,13 @@
</div>
<div class="grid gap-4 px-4 py-3.5 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-6">
<div class="narrative">
{#if sentences.length > 0}
<p class="text-[15px] leading-relaxed text-foreground">
<p
bind:this={textEl}
class="text-[15px] leading-relaxed text-foreground"
class:clamped={!expanded}
>
{sentences.join(' ')}
</p>
{:else}
@@ -125,6 +159,21 @@
</p>
{/if}
<!-- Always occupies its row on phones, even when the text fits: a
toggle that appears only sometimes is itself a layout shift. -->
<button
type="button"
class="toggle mt-1 cursor-pointer text-[13px] font-semibold text-primary hover:underline"
class:invisible={!overflows && !expanded}
aria-hidden={!overflows && !expanded}
tabindex={!overflows && !expanded ? -1 : 0}
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>
{expanded ? m.summary_read_less() : m.summary_read_more()}
</button>
</div>
<!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
<dl
class="grid grid-cols-2 gap-x-5 gap-y-2.5 text-sm sm:grid-cols-3 lg:w-80 lg:grid-cols-2 lg:border-l lg:border-border/60 lg:pl-6"
@@ -218,3 +267,36 @@
</div>
</div>
</section>
<style>
/* Phones: exactly five lines, reserved whether the text is long or short, so
the card's height never depends on the forecast that lands. 15px text at
leading-relaxed (1.625) → 5 lines = 8.125em. */
.clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
line-clamp: 5;
overflow: hidden;
}
.narrative p {
min-height: 8.125em;
}
/* md and up: no clamp, no reserved height, no toggle. */
@media (min-width: 768px) {
.clamped {
display: block;
overflow: visible;
}
.narrative p {
min-height: 0;
}
.toggle {
display: none;
}
}
</style>
@@ -16,7 +16,7 @@
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import {
type FetchedDaily,
type FetchedHourly,
@@ -322,6 +322,10 @@
// positioned relative to the data columns only.
let headerColWidth = $state(0);
let tableWidth = $state(0);
// Measured so the centred NOW badge can be clamped fully inside the row:
// centred on a time near midnight it would poke past the last column and
// hand the scroller a sliver of phantom overflow.
let nowBadgeWidth = $state(0);
let nowLeftPx = $derived(
nowPercent != null && tableWidth > 0
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
@@ -425,8 +429,9 @@
});
</script>
{#snippet weatherIcon(name: string, size: number = 16)}
{#snippet weatherIcon(name: string, size: number = 16, description: string = '')}
<svg class="inline-block fill-foreground" width={size} height={size}>
{#if description}<title>{description}</title>{/if}
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg>
{/snippet}
@@ -607,14 +612,17 @@
{#if nowPercent != null}
<div
class="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-red-500/75"
style="left:{nowPercent}%"
style="left:clamp(1px, {nowPercent}%, calc(100% - 1px))"
></div>
{/if}
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
<!-- "Now" label, aligned with the sunrise/sunset labels along the
bottom; its centre is clamped so the pill never leaves the row -->
{#if isTodaySelected && nowPercent != null}
<span
bind:clientWidth={nowBadgeWidth}
class="absolute bottom-0.5 z-15 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
style="left:{nowPercent}%"
style="left:clamp({nowBadgeWidth /
2}px, {nowPercent}%, calc(100% - {nowBadgeWidth / 2}px))"
>
{m.table_now()}
</span>
@@ -678,7 +686,11 @@
!cellData[i + 1].isDaytime}
>
{#if finite(wCode)}
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
{@render weatherIcon(
getWeatherIconName(wCode, cell.isDaytime),
iconPx,
getWeatherDescription(wCode)
)}
{/if}
</td>
{/each}
@@ -899,24 +911,31 @@
</tbody>
</table>
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
<!-- Hovered-column highlight, mirroring the meteogram crosshair.
max-width caps the box at the wrapper's true (fractional) right
edge: the widths here derive from rounded clientWidth bindings, so
on the last column left+width can land a fraction of a pixel past
the edge - enough scrollable overflow for a phantom scrollbar. -->
{#if hoveredCol >= 0 && tableWidth > 0}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const colLeft = headerColWidth + hoveredCol * colWidth}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
></div>
{/if}
<!-- "Now" column highlight. The current-time line itself is painted per
cell (.now-cell) so it stays under the values and icons. -->
cell (.now-cell) so it stays under the values and icons. Same
max-width cap as the hover highlight above. -->
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
{#if nowIdx >= 0}
{@const colLeft = headerColWidth + nowIdx * colWidth}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5"
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
></div>
{/if}
{/if}
@@ -64,9 +64,13 @@
if (val) onModelChange(val);
}}
>
<!-- Fixed width from sm up (mobile stays full-width): the trigger used to hug
its content, so its size changed with every model name and differed
between pages. One constant footprint, sized for the longest label in
the catalogue; anything longer truncates. -->
<Select.Trigger
aria-label={m.model_selector_aria({ label })}
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:w-80 sm:gap-3 sm:py-2 sm:flex-none"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
@@ -9,7 +9,7 @@
import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities';
import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather';
import { getWeatherIconName } from '../../utils/weather-codes';
import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import type { UnitPrefs } from '$lib/stores/settings';
@@ -113,6 +113,7 @@
>
<svg class="shrink-0 fill-foreground/80" width="34px" height="34px">
{#if day}
<title>{getWeatherDescription(day.weatherCode)}</title>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
day.weatherCode,
+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<title></title>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e0f2fe" />
<stop offset="1" stop-color="#bae6fd" />
</linearGradient>
<linearGradient id="canopy" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fb923c" />
<stop offset="1" stop-color="#ea580c" />
</linearGradient>
<linearGradient id="drop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#38bdf8" />
<stop offset="1" stop-color="#2563eb" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#sky)" />
<!-- raindrops falling onto the umbrella -->
<path d="M12 5c2 2.9 3 4.6 3 6.2a3 3 0 0 1-6 0c0-1.6 1-3.3 3-6.2Z" fill="url(#drop)" />
<path d="M53 4c2 2.9 3 4.6 3 6.2a3 3 0 0 1-6 0c0-1.6 1-3.3 3-6.2Z" fill="url(#drop)" />
<path d="M23 2.5c1.7 2.4 2.5 3.9 2.5 5.2a2.5 2.5 0 0 1-5 0c0-1.3.8-2.8 2.5-5.2Z" fill="url(#drop)" />
<path d="M45 12c1.7 2.4 2.5 3.9 2.5 5.2a2.5 2.5 0 0 1-5 0c0-1.3.8-2.8 2.5-5.2Z" fill="url(#drop)" />
<!-- pole with curved handle -->
<path
d="M33 35v17a4.5 4.5 0 0 1-9 0"
fill="none"
stroke="#475569"
stroke-width="3.25"
stroke-linecap="round"
/>
<!-- canopy tip -->
<path d="M33 13.5v4" fill="none" stroke="#475569" stroke-width="3" stroke-linecap="round" />
<!-- canopy with scalloped edge -->
<path
d="M11 37c0-11.6 9.8-21 22-21s22 9.4 22 21c-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.4 0-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.4 0-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.3 0Z"
fill="url(#canopy)"
/>
<!-- ribs -->
<path
d="M33 16.5c-5.2 3-7.4 11-7.3 19M33 16.5c5.2 3 7.4 11 7.3 19"
fill="none"
stroke="#9a3412"
stroke-width="1.5"
opacity="0.35"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+15 -1
View File
@@ -45,12 +45,26 @@ const config = {
];
const localized = locales.flatMap((locale) => shared.map((p) => `/${locale}${p}`));
// Every per-location route, not just the week page: an unprerendered
// path is served by the SPA fallback, which the host answers with a
// 404 status. The page still works, but it costs a bogus 404 on every
// hard reload (and tells crawlers the page does not exist).
const cityRoutes = [
'/weather/week',
'/weather/compare',
'/weather/14-day',
'/weather/seasonal',
'/weather/historical'
];
try {
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
const raw = fs.readFileSync(citiesPath, 'utf-8');
const cities = JSON.parse(raw);
if (Array.isArray(cities)) {
const cityEntries = cities.map((c) => `/en/weather/week/${c}`);
const cityEntries = cities.flatMap((c) =>
cityRoutes.map((route) => `/en${route}/${c}`)
);
// Keep the default wildcard to include other routes
return ['*', ...localized, ...cityEntries];
}