finish animations and add elevation
This commit is contained in:
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"nav_week": "7-Tage-Vorhersage",
|
"nav_week": "Wochenvorhersage",
|
||||||
"nav_compare": "Modellvergleich",
|
"nav_compare": "Modellvergleich",
|
||||||
"nav_14day": "14-Tage-Vorhersage",
|
"nav_14day": "14-Tage-Vorhersage",
|
||||||
"nav_seasonal": "Saisonal",
|
"nav_seasonal": "Saisonal",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"day_today": "Heute",
|
"day_today": "Heute",
|
||||||
"day_tomorrow": "Morgen",
|
"day_tomorrow": "Morgen",
|
||||||
"day_yesterday": "Gestern",
|
"day_yesterday": "Gestern",
|
||||||
"page_week_subtitle": "7-Tage-Vorhersage",
|
"page_week_subtitle": "Wochenvorhersage",
|
||||||
"page_compare_subtitle": "Modellvergleich",
|
"page_compare_subtitle": "Modellvergleich",
|
||||||
"page_14day_subtitle": "14-Tage-Ensemblevorhersage",
|
"page_14day_subtitle": "14-Tage-Ensemblevorhersage",
|
||||||
"page_seasonal_subtitle": "Saisonale Aussichten",
|
"page_seasonal_subtitle": "Saisonale Aussichten",
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
"legend_show": "Legende anzeigen",
|
"legend_show": "Legende anzeigen",
|
||||||
"meteograms_none_before": "Keine Meteogramme konfiguriert,",
|
"meteograms_none_before": "Keine Meteogramme konfiguriert,",
|
||||||
"meteograms_none_action": "fügen Sie Variablen hinzu",
|
"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_searching": "Wird gesucht…",
|
||||||
"search_favorites": "Favoriten",
|
"search_favorites": "Favoriten",
|
||||||
"search_recent": "Zuletzt",
|
"search_recent": "Zuletzt",
|
||||||
@@ -282,6 +282,7 @@
|
|||||||
"model_automatic_selection": "Automatische Auswahl",
|
"model_automatic_selection": "Automatische Auswahl",
|
||||||
"model_selector_aria": "Auswahl: {label}",
|
"model_selector_aria": "Auswahl: {label}",
|
||||||
"compare_models_heading": "Modelle",
|
"compare_models_heading": "Modelle",
|
||||||
|
"compare_models_choose": "Modelle für den Vergleich wählen",
|
||||||
"compare_variables_heading": "Stündliche Wettervariablen",
|
"compare_variables_heading": "Stündliche Wettervariablen",
|
||||||
"compare_timeline_title": "Zeitleiste des Modellvergleichs",
|
"compare_timeline_title": "Zeitleiste des Modellvergleichs",
|
||||||
"compare_model_label": "Modell",
|
"compare_model_label": "Modell",
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"nav_week": "7-Day Forecast",
|
"nav_week": "Weekly Forecast",
|
||||||
"nav_compare": "Model Comparison",
|
"nav_compare": "Model Comparison",
|
||||||
"nav_14day": "14-Day Forecast",
|
"nav_14day": "14-Day Forecast",
|
||||||
"nav_seasonal": "Seasonal",
|
"nav_seasonal": "Seasonal",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"day_today": "Today",
|
"day_today": "Today",
|
||||||
"day_tomorrow": "Tomorrow",
|
"day_tomorrow": "Tomorrow",
|
||||||
"day_yesterday": "Yesterday",
|
"day_yesterday": "Yesterday",
|
||||||
"page_week_subtitle": "7-day forecast",
|
"page_week_subtitle": "Weekly forecast",
|
||||||
"page_compare_subtitle": "Model comparison",
|
"page_compare_subtitle": "Model comparison",
|
||||||
"page_14day_subtitle": "14-day ensemble forecast",
|
"page_14day_subtitle": "14-day ensemble forecast",
|
||||||
"page_seasonal_subtitle": "Seasonal outlook",
|
"page_seasonal_subtitle": "Seasonal outlook",
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
"legend_show": "Show legend",
|
"legend_show": "Show legend",
|
||||||
"meteograms_none_before": "No meteograms configured,",
|
"meteograms_none_before": "No meteograms configured,",
|
||||||
"meteograms_none_action": "add some variables",
|
"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_searching": "Searching...",
|
||||||
"search_favorites": "Favorites",
|
"search_favorites": "Favorites",
|
||||||
"search_recent": "Recent",
|
"search_recent": "Recent",
|
||||||
@@ -282,6 +282,7 @@
|
|||||||
"model_automatic_selection": "Automatic selection",
|
"model_automatic_selection": "Automatic selection",
|
||||||
"model_selector_aria": "{label} selection",
|
"model_selector_aria": "{label} selection",
|
||||||
"compare_models_heading": "Models",
|
"compare_models_heading": "Models",
|
||||||
|
"compare_models_choose": "Choose which models to plot",
|
||||||
"compare_variables_heading": "Hourly Weather Variables",
|
"compare_variables_heading": "Hourly Weather Variables",
|
||||||
"compare_timeline_title": "Model Comparison Timeline",
|
"compare_timeline_title": "Model Comparison Timeline",
|
||||||
"compare_model_label": "Model",
|
"compare_model_label": "Model",
|
||||||
|
|||||||
+8
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$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_compare": "Comparación de modelos",
|
||||||
"nav_14day": "Pronóstico de 14 días",
|
"nav_14day": "Pronóstico de 14 días",
|
||||||
"nav_seasonal": "Estacional",
|
"nav_seasonal": "Estacional",
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
"day_today": "Hoy",
|
"day_today": "Hoy",
|
||||||
"day_tomorrow": "Mañana",
|
"day_tomorrow": "Mañana",
|
||||||
"day_yesterday": "Ayer",
|
"day_yesterday": "Ayer",
|
||||||
"page_week_subtitle": "pronóstico de 7 días",
|
"page_week_subtitle": "Pronóstico semanal",
|
||||||
"page_compare_subtitle": "comparación de modelos",
|
"page_compare_subtitle": "Comparación de modelos",
|
||||||
"page_14day_subtitle": "pronóstico de conjunto a 14 días",
|
"page_14day_subtitle": "Pronóstico de conjunto a 14 días",
|
||||||
"page_seasonal_subtitle": "previsión estacional",
|
"page_seasonal_subtitle": "Previsión estacional",
|
||||||
"page_historical_subtitle": "clima histórico",
|
"page_historical_subtitle": "Clima histórico",
|
||||||
"hourly_heading": "por horas",
|
"hourly_heading": "por horas",
|
||||||
"hourly_variables": "Variables",
|
"hourly_variables": "Variables",
|
||||||
"meteograms_heading": "Meteogramas",
|
"meteograms_heading": "Meteogramas",
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
"legend_show": "Mostrar leyenda",
|
"legend_show": "Mostrar leyenda",
|
||||||
"meteograms_none_before": "No hay meteogramas configurados,",
|
"meteograms_none_before": "No hay meteogramas configurados,",
|
||||||
"meteograms_none_action": "añade algunas variables",
|
"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_searching": "Buscando…",
|
||||||
"search_favorites": "Favoritos",
|
"search_favorites": "Favoritos",
|
||||||
"search_recent": "Recientes",
|
"search_recent": "Recientes",
|
||||||
@@ -282,6 +282,7 @@
|
|||||||
"model_automatic_selection": "Selección automática",
|
"model_automatic_selection": "Selección automática",
|
||||||
"model_selector_aria": "Selección de {label}",
|
"model_selector_aria": "Selección de {label}",
|
||||||
"compare_models_heading": "Modelos",
|
"compare_models_heading": "Modelos",
|
||||||
|
"compare_models_choose": "Elige los modelos a comparar",
|
||||||
"compare_variables_heading": "Variables meteorológicas horarias",
|
"compare_variables_heading": "Variables meteorológicas horarias",
|
||||||
"compare_timeline_title": "Cronología de comparación de modelos",
|
"compare_timeline_title": "Cronología de comparación de modelos",
|
||||||
"compare_model_label": "Modelo",
|
"compare_model_label": "Modelo",
|
||||||
|
|||||||
+8
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$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_compare": "Comparaison des modèles",
|
||||||
"nav_14day": "Prévisions à 14 jours",
|
"nav_14day": "Prévisions à 14 jours",
|
||||||
"nav_seasonal": "Saisonnier",
|
"nav_seasonal": "Saisonnier",
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
"day_today": "Aujourd'hui",
|
"day_today": "Aujourd'hui",
|
||||||
"day_tomorrow": "Demain",
|
"day_tomorrow": "Demain",
|
||||||
"day_yesterday": "Hier",
|
"day_yesterday": "Hier",
|
||||||
"page_week_subtitle": "prévisions à 7 jours",
|
"page_week_subtitle": "Prévisions hebdomadaires",
|
||||||
"page_compare_subtitle": "comparaison des modèles",
|
"page_compare_subtitle": "Comparaison des modèles",
|
||||||
"page_14day_subtitle": "prévision d'ensemble à 14 jours",
|
"page_14day_subtitle": "Prévision d'ensemble à 14 jours",
|
||||||
"page_seasonal_subtitle": "tendances saisonnières",
|
"page_seasonal_subtitle": "Tendances saisonnières",
|
||||||
"page_historical_subtitle": "météo historique",
|
"page_historical_subtitle": "Météo historique",
|
||||||
"hourly_heading": "par heure",
|
"hourly_heading": "par heure",
|
||||||
"hourly_variables": "Variables",
|
"hourly_variables": "Variables",
|
||||||
"meteograms_heading": "Météogrammes",
|
"meteograms_heading": "Météogrammes",
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
"legend_show": "Afficher la légende",
|
"legend_show": "Afficher la légende",
|
||||||
"meteograms_none_before": "Aucun météogramme configuré,",
|
"meteograms_none_before": "Aucun météogramme configuré,",
|
||||||
"meteograms_none_action": "ajoutez des variables",
|
"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_searching": "Recherche…",
|
||||||
"search_favorites": "Favoris",
|
"search_favorites": "Favoris",
|
||||||
"search_recent": "Récents",
|
"search_recent": "Récents",
|
||||||
@@ -282,6 +282,7 @@
|
|||||||
"model_automatic_selection": "Sélection automatique",
|
"model_automatic_selection": "Sélection automatique",
|
||||||
"model_selector_aria": "Sélection : {label}",
|
"model_selector_aria": "Sélection : {label}",
|
||||||
"compare_models_heading": "Modèles",
|
"compare_models_heading": "Modèles",
|
||||||
|
"compare_models_choose": "Choisir les modèles à comparer",
|
||||||
"compare_variables_heading": "Variables météo horaires",
|
"compare_variables_heading": "Variables météo horaires",
|
||||||
"compare_timeline_title": "Chronologie de comparaison des modèles",
|
"compare_timeline_title": "Chronologie de comparaison des modèles",
|
||||||
"compare_model_label": "Modèle",
|
"compare_model_label": "Modèle",
|
||||||
|
|||||||
+8
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"nav_week": "Previsioni a 7 giorni",
|
"nav_week": "Previsioni settimanali",
|
||||||
"nav_compare": "Confronto modelli",
|
"nav_compare": "Confronto modelli",
|
||||||
"nav_14day": "Previsioni a 14 giorni",
|
"nav_14day": "Previsioni a 14 giorni",
|
||||||
"nav_seasonal": "Stagionale",
|
"nav_seasonal": "Stagionale",
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
"day_today": "Oggi",
|
"day_today": "Oggi",
|
||||||
"day_tomorrow": "Domani",
|
"day_tomorrow": "Domani",
|
||||||
"day_yesterday": "Ieri",
|
"day_yesterday": "Ieri",
|
||||||
"page_week_subtitle": "previsioni a 7 giorni",
|
"page_week_subtitle": "Previsioni settimanali",
|
||||||
"page_compare_subtitle": "confronto modelli",
|
"page_compare_subtitle": "Confronto modelli",
|
||||||
"page_14day_subtitle": "previsione d'insieme a 14 giorni",
|
"page_14day_subtitle": "Previsione d'insieme a 14 giorni",
|
||||||
"page_seasonal_subtitle": "tendenze stagionali",
|
"page_seasonal_subtitle": "Tendenze stagionali",
|
||||||
"page_historical_subtitle": "meteo storico",
|
"page_historical_subtitle": "Meteo storico",
|
||||||
"hourly_heading": "orario",
|
"hourly_heading": "orario",
|
||||||
"hourly_variables": "Variabili",
|
"hourly_variables": "Variabili",
|
||||||
"meteograms_heading": "Meteogrammi",
|
"meteograms_heading": "Meteogrammi",
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
"legend_show": "Mostra legenda",
|
"legend_show": "Mostra legenda",
|
||||||
"meteograms_none_before": "Nessun meteogramma configurato,",
|
"meteograms_none_before": "Nessun meteogramma configurato,",
|
||||||
"meteograms_none_action": "aggiungi qualche variabile",
|
"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_searching": "Ricerca in corso…",
|
||||||
"search_favorites": "Preferiti",
|
"search_favorites": "Preferiti",
|
||||||
"search_recent": "Recenti",
|
"search_recent": "Recenti",
|
||||||
@@ -282,6 +282,7 @@
|
|||||||
"model_automatic_selection": "Selezione automatica",
|
"model_automatic_selection": "Selezione automatica",
|
||||||
"model_selector_aria": "Selezione di {label}",
|
"model_selector_aria": "Selezione di {label}",
|
||||||
"compare_models_heading": "Modelli",
|
"compare_models_heading": "Modelli",
|
||||||
|
"compare_models_choose": "Scegli i modelli da confrontare",
|
||||||
"compare_variables_heading": "Variabili meteo orarie",
|
"compare_variables_heading": "Variabili meteo orarie",
|
||||||
"compare_timeline_title": "Cronologia del confronto tra modelli",
|
"compare_timeline_title": "Cronologia del confronto tra modelli",
|
||||||
"compare_model_label": "Modello",
|
"compare_model_label": "Modello",
|
||||||
|
|||||||
@@ -45,8 +45,16 @@
|
|||||||
// separators spelled out in the template lose their spacing to Svelte's
|
// separators spelled out in the template lose their spacing to Svelte's
|
||||||
// whitespace trimming ("Canton of Schwyz,Switzerland").
|
// whitespace trimming ("Canton of Schwyz,Switzerland").
|
||||||
let locationRegion = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
|
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
|
// the pill ellipses, so the full name still has to be readable somewhere
|
||||||
let locationLine = $derived([location?.name, locationRegion].filter(Boolean).join(' · '));
|
let locationLine = $derived([location?.name, locationDetail].filter(Boolean).join(' · '));
|
||||||
|
|
||||||
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
||||||
const themeTitles: Record<Theme, () => string> = {
|
const themeTitles: Record<Theme, () => string> = {
|
||||||
@@ -120,8 +128,8 @@
|
|||||||
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
||||||
<span class="min-w-0 truncate text-sm font-semibold text-foreground" title={locationLine}>
|
<span class="min-w-0 truncate text-sm font-semibold text-foreground" title={locationLine}>
|
||||||
{location.name}
|
{location.name}
|
||||||
{#if locationRegion}
|
{#if locationDetail}
|
||||||
<span class="font-normal text-muted-foreground">· {locationRegion}</span>
|
<span class="font-normal text-muted-foreground">· {locationDetail}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ import { writable } from 'svelte/store';
|
|||||||
*/
|
*/
|
||||||
export const pageContentReady = writable(true);
|
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.
|
* Called by the layout before it swaps to a route that fetches its own data.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tick } from 'svelte';
|
|||||||
import {
|
import {
|
||||||
canStartViewTransition,
|
canStartViewTransition,
|
||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
|
skipActiveViewTransition,
|
||||||
startViewTransition,
|
startViewTransition,
|
||||||
supportsViewTransitions
|
supportsViewTransitions
|
||||||
} from './view-transition';
|
} from './view-transition';
|
||||||
@@ -21,15 +22,40 @@ import {
|
|||||||
* applies: starting a rival transition would skip the running one and flash the
|
* applies: starting a rival transition would skip the running one and flash the
|
||||||
* page (see view-transition.ts).
|
* 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> {
|
export async function runDayTransition(update: () => void): Promise<void> {
|
||||||
if (!canStartViewTransition()) {
|
if (!canStartViewTransition()) {
|
||||||
update();
|
update();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Svelte applies the change on the next tick; the transition has to wait for
|
const token = ++dayTransitionToken;
|
||||||
// that before it snapshots the new state. `day-switch` scopes which regions
|
const root = document.documentElement;
|
||||||
// take part (see routes/layout.css) and is cleared for us when it ends.
|
|
||||||
|
// The region snapshots include the part of the table normally scrolled up
|
||||||
|
// behind the sticky strip; the transition overlay is clipped at the bar's
|
||||||
|
// bottom edge so they cannot paint over the (live, clickable) strip.
|
||||||
|
// Measured per switch because the bar's height follows the scroll collapse.
|
||||||
|
const bar = document.querySelector('.daystrip .strip-row');
|
||||||
|
if (bar) {
|
||||||
|
const clip = Math.max(0, bar.getBoundingClientRect().bottom);
|
||||||
|
root.style.setProperty('--day-switch-clip', `${clip}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page stays scrollable during the fade, but the snapshots and the clip
|
||||||
|
// line above are anchored to where things were at capture - so the first
|
||||||
|
// sign of scrolling finishes the fade on the spot instead of animating
|
||||||
|
// against a moving page.
|
||||||
|
const skip = () => skipActiveViewTransition();
|
||||||
|
window.addEventListener('wheel', skip, { passive: true });
|
||||||
|
window.addEventListener('touchmove', skip, { passive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Svelte applies the change on the next tick; the transition has to wait
|
||||||
|
// for that before it snapshots the new state. `day-switch` scopes which
|
||||||
|
// regions take part (see routes/layout.css) and is cleared when it ends.
|
||||||
await startViewTransition(
|
await startViewTransition(
|
||||||
async () => {
|
async () => {
|
||||||
update();
|
update();
|
||||||
@@ -37,6 +63,12 @@ export async function runDayTransition(update: () => void): Promise<void> {
|
|||||||
},
|
},
|
||||||
{ rootClass: 'day-switch' }
|
{ rootClass: 'day-switch' }
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener('wheel', skip);
|
||||||
|
window.removeEventListener('touchmove', skip);
|
||||||
|
// a newer switch owns the clip var now; only the last one may clear it
|
||||||
|
if (token === dayTransitionToken) root.style.removeProperty('--day-switch-clip');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,6 +42,29 @@ interface Options {
|
|||||||
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
|
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
|
||||||
let capturing: Promise<void> | null = null;
|
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 =>
|
export const supportsViewTransitions = (): boolean =>
|
||||||
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
|
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
|
||||||
|
|
||||||
@@ -76,9 +99,13 @@ export function startViewTransition(update: UpdateCallback, options: Options = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
if (rootClass) root.classList.add(rootClass);
|
if (rootClass) {
|
||||||
|
rootClassHolds.set(rootClass, (rootClassHolds.get(rootClass) ?? 0) + 1);
|
||||||
|
root.classList.add(rootClass);
|
||||||
|
}
|
||||||
|
|
||||||
const transition = document.startViewTransition(update);
|
const transition = document.startViewTransition(update);
|
||||||
|
active = transition;
|
||||||
|
|
||||||
// A throw inside the callback rejects `updateCallbackDone` as well as
|
// A throw inside the callback rejects `updateCallbackDone` as well as
|
||||||
// `finished`. Nothing awaits the former, and an unhandled rejection there is
|
// `finished`. Nothing awaits the former, and an unhandled rejection there is
|
||||||
@@ -102,6 +129,14 @@ export function startViewTransition(update: UpdateCallback, options: Options = {
|
|||||||
() => {}
|
() => {}
|
||||||
)
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (rootClass) root.classList.remove(rootClass);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
mapTransitionCover,
|
||||||
markPageLoading,
|
markPageLoading,
|
||||||
markPageReady,
|
markPageReady,
|
||||||
pageContentReady
|
pageContentReady
|
||||||
@@ -104,10 +105,14 @@
|
|||||||
const OVERLAY_CEILING_MS = 15000;
|
const OVERLAY_CEILING_MS = 15000;
|
||||||
|
|
||||||
// The map is a cross-origin iframe, and a browser does not paint one into a
|
// The map is a cross-origin iframe, and a browser does not paint one into a
|
||||||
// view transition snapshot. Any transition with the maps page on either side
|
// view transition snapshot - captured bare, any transition with the maps page
|
||||||
// animates a hole where the map is: leaving it, the map drops out at frame one
|
// on either side would animate a hole where the map is. The way out is to
|
||||||
// and that blank sits under the incoming page for the whole run. Swapping
|
// make sure the map is never what gets captured: the maps page keeps an
|
||||||
// outright is the honest answer - there is nothing here to cross-fade.
|
// 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 MAPS_ROUTE = '/weather/maps';
|
||||||
const onMapsPage = () => routePath(get(page).url.pathname).startsWith(MAPS_ROUTE);
|
const onMapsPage = () => routePath(get(page).url.pathname).startsWith(MAPS_ROUTE);
|
||||||
|
|
||||||
@@ -187,7 +192,7 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the navigation lands on the route and params the page is already
|
* True when the navigation lands on the route and params the page is already
|
||||||
* showing - the sidebar's home link from the 7-day page it points at, or a
|
* showing - the sidebar's home link from the week page it points at, or a
|
||||||
* link that differs only in the query string.
|
* link that differs only in the query string.
|
||||||
*
|
*
|
||||||
* SvelteKit keeps the page component mounted for those, and nothing it holds
|
* SvelteKit keeps the page component mounted for those, and nothing it holds
|
||||||
@@ -206,7 +211,7 @@
|
|||||||
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
|
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
onNavigate((navigation) => {
|
onNavigate(async (navigation) => {
|
||||||
const pending =
|
const pending =
|
||||||
PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
|
PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
|
||||||
// Either way the flag is set explicitly: leaving a page that never resolved
|
// Either way the flag is set explicitly: leaving a page that never resolved
|
||||||
@@ -217,9 +222,16 @@
|
|||||||
// `startViewTransition` decides whether a transition is possible at all
|
// `startViewTransition` decides whether a transition is possible at all
|
||||||
// (support, reduced motion, one already capturing) and runs the update
|
// (support, reduced motion, one already capturing) and runs the update
|
||||||
// inline when it is not - so there is exactly one path from here down.
|
// inline when it is not - so there is exactly one path from here down.
|
||||||
const touchesMap =
|
const underTransition = canStartViewTransition();
|
||||||
navigation.from?.route?.id === MAPS_ROUTE || navigation.to?.route?.id === MAPS_ROUTE;
|
|
||||||
const underTransition = canStartViewTransition() && !touchesMap;
|
// Leaving the maps page: cover the iframe before the outgoing state is
|
||||||
|
// captured, so the snapshot holds a clean panel instead of a hole where
|
||||||
|
// the map was. The tick makes sure the cover is actually in the DOM by
|
||||||
|
// the time the capture reads it.
|
||||||
|
if (underTransition && navigation.from?.route?.id === MAPS_ROUTE) {
|
||||||
|
mapTransitionCover.set(true);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise<void>((swap) => {
|
return new Promise<void>((swap) => {
|
||||||
void startViewTransition(
|
void startViewTransition(
|
||||||
@@ -242,6 +254,11 @@
|
|||||||
let mainEl = $state<HTMLElement | null>(null);
|
let mainEl = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
afterNavigate((navigation) => {
|
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
|
// 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.
|
// handling never touches it and a new page would open half way down.
|
||||||
// Back/forward and in-page anchors keep their position.
|
// Back/forward and in-page anchors keep their position.
|
||||||
|
|||||||
+39
-36
@@ -135,9 +135,18 @@
|
|||||||
|
|
||||||
/* ── Day switching ────────────────────────────────────────────────────────
|
/* ── Day switching ────────────────────────────────────────────────────────
|
||||||
Only the three regions whose content depends on the selected day take part
|
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`
|
in the cross-fade - and nothing else is captured at all. A captured
|
||||||
class cancels the root animation, so the strip, header and page chrome do
|
element is neither painted nor hit-testable in the live page for the
|
||||||
not so much as flicker while the table, summary and charts swap over. */
|
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 {
|
:root.day-switch .day-region-table {
|
||||||
view-transition-name: day-table;
|
view-transition-name: day-table;
|
||||||
}
|
}
|
||||||
@@ -148,23 +157,20 @@
|
|||||||
view-transition-name: day-charts;
|
view-transition-name: day-charts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The sticky day strip never changes during a day switch, but it has to be
|
/* Region snapshots paint in a viewport-fixed layer above the live page, at
|
||||||
captured too: view-transition snapshots all paint in one layer above the
|
their full layout size - including the part of the table normally
|
||||||
page, so without its own group (and a higher z-index in that layer) the
|
scrolled up behind the sticky strip and the topbar. The strip used to be
|
||||||
fading table and charts would slide over the top of it. */
|
captured purely to stay on top of that, but a captured strip is dead to
|
||||||
:root.day-switch .daystrip {
|
input; instead the whole overlay is clipped at the strip bar's bottom
|
||||||
view-transition-name: daystrip;
|
edge (measured per switch by runDayTransition), so the snapshots stay
|
||||||
}
|
out of the chrome and the chrome stays live. */
|
||||||
::view-transition-group(daystrip) {
|
:root.day-switch::view-transition {
|
||||||
z-index: 20;
|
clip-path: inset(var(--day-switch-clip, 0px) 0 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Same for the topbar and the sidebar: a captured region's snapshot is
|
/* Page swaps capture the topbar and sidebar: a region that is identical on
|
||||||
painted at its layout position, including the part normally scrolled up
|
both sides should not be animated at all, so they are pinned here and
|
||||||
behind the chrome. Both are also identical either side of a *page* swap,
|
swapped outright below. */
|
||||||
so they are captured there too and pinned below - a region that does not
|
|
||||||
change should not be animated at all. */
|
|
||||||
:root.day-switch .topbar,
|
|
||||||
:root.page-switch .topbar {
|
:root.page-switch .topbar {
|
||||||
view-transition-name: topbar;
|
view-transition-name: topbar;
|
||||||
}
|
}
|
||||||
@@ -178,24 +184,27 @@
|
|||||||
z-index: 25;
|
z-index: 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The root fade is the page swap's own; during a day switch the chrome must
|
|
||||||
not so much as flicker, so it is cancelled outright. */
|
|
||||||
:root.day-switch::view-transition-old(root),
|
|
||||||
:root.day-switch::view-transition-new(root) {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.day-region-table,
|
.day-region-table,
|
||||||
.day-region-summary,
|
.day-region-summary,
|
||||||
.day-region-charts,
|
.day-region-charts,
|
||||||
.daystrip,
|
|
||||||
.topbar,
|
.topbar,
|
||||||
.sidebar-region {
|
.sidebar-region {
|
||||||
view-transition-name: none;
|
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 ──────────────────────────────────────────
|
/* ── Cross-fades that do not dip ──────────────────────────────────────────
|
||||||
The default cross-fade is NOT opacity-neutral: coverage of two stacked
|
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
|
layers is `new + old * (1 - new)`, so at the midpoint the pair covers only
|
||||||
@@ -240,21 +249,15 @@
|
|||||||
mix-blend-mode: plus-lighter;
|
mix-blend-mode: plus-lighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chrome captured only so the fading regions cannot paint over it. The strip
|
/* Pinned chrome swaps outright: identical on both sides, so any animation
|
||||||
does change - the selected day moves - but that change belongs to the
|
would only risk a flicker. */
|
||||||
regions' cross-fade, not to the strip, so it swaps outright: the outgoing
|
|
||||||
snapshot is dropped rather than held, because the selected cell is a
|
|
||||||
translucent `bg-primary/10` tint and anything left underneath shows
|
|
||||||
through it as a second, doubled label. */
|
|
||||||
::view-transition-old(topbar),
|
::view-transition-old(topbar),
|
||||||
::view-transition-old(sidebar),
|
::view-transition-old(sidebar) {
|
||||||
::view-transition-old(daystrip) {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
::view-transition-new(topbar),
|
::view-transition-new(topbar),
|
||||||
::view-transition-new(sidebar),
|
::view-transition-new(sidebar) {
|
||||||
::view-transition-new(daystrip) {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,18 @@
|
|||||||
|
|
||||||
// Joined here rather than in the markup: Svelte trims the whitespace around a
|
// 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
|
// line break, so a separator written as "{admin1},\n{country}" renders as
|
||||||
// "Canton of Schwyz,Switzerland".
|
// "Canton of Schwyz,Switzerland". Elevation joins the same line; from lg up
|
||||||
let region = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
|
// the whole line is hidden, because the topbar pill carries the region and
|
||||||
|
// elevation there.
|
||||||
|
let region = $derived(
|
||||||
|
[
|
||||||
|
location?.admin1,
|
||||||
|
location?.country,
|
||||||
|
location?.elevation != null ? `${Math.round(location.elevation)}m` : null
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if subtitle && location}
|
{#if subtitle && location}
|
||||||
@@ -60,11 +70,11 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||||
{location.name}
|
{location.name}
|
||||||
|
<span class="font-medium text-muted-foreground">· {subtitle}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="truncate text-sm text-muted-foreground">
|
{#if region}
|
||||||
{#if region}<span class="lg:hidden">{region}<span class="mx-1 opacity-50">·</span></span
|
<p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
|
||||||
>{/if}{subtitle}
|
{/if}
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -298,7 +298,7 @@
|
|||||||
|
|
||||||
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
|
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
|
||||||
{#snippet heroActions()}
|
{#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
|
<ModelSelector
|
||||||
selectedModel={params.models?.[0] ?? DEFAULT_MODEL}
|
selectedModel={params.models?.[0] ?? DEFAULT_MODEL}
|
||||||
groups={ensembleModelGroups}
|
groups={ensembleModelGroups}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
// components persist across refetches; entries are null while unmounted
|
// components persist across refetches; entries are null while unmounted
|
||||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||||
|
|
||||||
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
|
// ─── Zoom range controls (mirrors the week-page meteograms) ─────────────────────
|
||||||
|
|
||||||
const SECONDS_PER_DAY = 24 * 3600;
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
|
|
||||||
@@ -321,9 +321,62 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- the zoom controls ride in the layout's location row (see weather/+layout) -->
|
<!-- the models button and zoom controls ride in the layout's location row -->
|
||||||
{#snippet heroActions()}
|
{#snippet heroActions()}
|
||||||
<!-- Range / zoom controls, aligned with the title like the other pages -->
|
<!-- Hero models control: same footprint and look as the model selector on
|
||||||
|
the other pages, so the title row lines up everywhere. Model selection
|
||||||
|
here is a multi-select grid further down, so this hands over to it. -->
|
||||||
|
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex h-auto min-h-12 w-full min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 pe-3 text-left shadow-sm transition-colors hover:border-primary/70 hover:shadow-md sm:min-h-14 sm:w-80 sm:flex-none sm:gap-3 sm:py-2"
|
||||||
|
onclick={() =>
|
||||||
|
document.getElementById('models')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
|
||||||
|
>
|
||||||
|
<!-- layered-globe icon, matching the model selector -->
|
||||||
|
<svg
|
||||||
|
class="size-4.5 sm:size-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col items-start gap-0 overflow-hidden">
|
||||||
|
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
|
||||||
|
{m.compare_models_heading()}
|
||||||
|
</span>
|
||||||
|
<span class="max-w-full truncate text-[13px] font-bold text-foreground sm:text-sm">
|
||||||
|
{params.models?.length || 0} / {models.flat().length}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="hidden max-w-full truncate text-[11px] leading-tight text-muted-foreground sm:block"
|
||||||
|
>
|
||||||
|
{m.compare_models_choose()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="size-4 shrink-0 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Range / zoom controls, floated over the chart area on lg -->
|
||||||
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
|
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
|
||||||
<span class="hidden text-xs text-muted-foreground lg:inline">
|
<span class="hidden text-xs text-muted-foreground lg:inline">
|
||||||
{m.meteograms_zoom_hint()}
|
{m.meteograms_zoom_hint()}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@
|
|||||||
|
|
||||||
<!-- the reanalysis picker rides in the layout's location row -->
|
<!-- the reanalysis picker rides in the layout's location row -->
|
||||||
{#snippet heroActions()}
|
{#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
|
<ModelSelector
|
||||||
selectedModel={archiveModel}
|
selectedModel={archiveModel}
|
||||||
groups={archiveModelGroups}
|
groups={archiveModelGroups}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
let chartComponents: (CanvasChart | null)[] = $state([]);
|
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||||||
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null));
|
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(
|
let panels = $derived(
|
||||||
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
|
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
import { mapTransitionCover, reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||||
import { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
|
import { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { mapsDomainForModel } from '$lib/utils/maps-domain';
|
import { mapsDomainForModel } from '$lib/utils/maps-domain';
|
||||||
@@ -27,6 +28,14 @@
|
|||||||
// backstop, so a map that fails to boot still releases the page.
|
// backstop, so a map that fails to boot still releases the page.
|
||||||
reportPageReady(() => mapReady || frameLoaded);
|
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>) => {
|
const postToMap = (message: Record<string, unknown>) => {
|
||||||
iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN);
|
iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN);
|
||||||
};
|
};
|
||||||
@@ -93,7 +102,7 @@
|
|||||||
|
|
||||||
<!-- Full-bleed map: the layout drops its padding for this route. The map
|
<!-- Full-bleed map: the layout drops its padding for this route. The map
|
||||||
follows our theme through the color-scheme declared on :root/.dark -->
|
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
|
<!-- allow="cross-origin-isolated" delegates SharedArrayBuffer use to the
|
||||||
map; it only takes effect when this site itself is served with
|
map; it only takes effect when this site itself is served with
|
||||||
COOP/COEP headers (see README, Deployment) -->
|
COOP/COEP headers (see README, Deployment) -->
|
||||||
@@ -107,6 +116,20 @@
|
|||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
onload={() => (frameLoaded = true)}
|
onload={() => (frameLoaded = true)}
|
||||||
class="block h-full w-full border-0"
|
class="block h-full w-full border-0"
|
||||||
|
class:invisible={$mapTransitionCover}
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
></iframe>
|
></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>
|
</div>
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
<!-- Out of flow on lg+ (the hero row is `relative`), same as the week, 14-day
|
<!-- 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
|
and archive pages: the controls then cannot move the heading when they
|
||||||
change size. -->
|
change size. -->
|
||||||
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto lg:absolute lg:top-0 lg:right-0">
|
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
|
||||||
<!-- Range buttons reslice the already-fetched horizon (no refetch). While a
|
<!-- Range buttons reslice the already-fetched horizon (no refetch). While a
|
||||||
forecast is on its way they stay mounted but invisible, because
|
forecast is on its way they stay mounted but invisible, because
|
||||||
mounting them on arrival re-flowed the row and nudged the heading.
|
mounting them on arrival re-flowed the row and nudged the heading.
|
||||||
|
|||||||
@@ -346,12 +346,12 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Drizz.li | {m.page_week_title()}</title>
|
<title>Drizz.li | {m.page_week_title()}</title>
|
||||||
<link rel="canonical" href="https://drizz.li/weather/week" />
|
<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>
|
</svelte:head>
|
||||||
|
|
||||||
<!-- the model picker rides in the layout's location row (see weather/+layout) -->
|
<!-- the model picker rides in the layout's location row (see weather/+layout) -->
|
||||||
{#snippet heroActions()}
|
{#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
|
<ModelSelector
|
||||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||||
onModelChange={(model) => {
|
onModelChange={(model) => {
|
||||||
|
|||||||
@@ -322,6 +322,10 @@
|
|||||||
// positioned relative to the data columns only.
|
// positioned relative to the data columns only.
|
||||||
let headerColWidth = $state(0);
|
let headerColWidth = $state(0);
|
||||||
let tableWidth = $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(
|
let nowLeftPx = $derived(
|
||||||
nowPercent != null && tableWidth > 0
|
nowPercent != null && tableWidth > 0
|
||||||
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
|
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
|
||||||
@@ -608,14 +612,17 @@
|
|||||||
{#if nowPercent != null}
|
{#if nowPercent != null}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-red-500/75"
|
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>
|
></div>
|
||||||
{/if}
|
{/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}
|
{#if isTodaySelected && nowPercent != null}
|
||||||
<span
|
<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"
|
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()}
|
{m.table_now()}
|
||||||
</span>
|
</span>
|
||||||
@@ -904,24 +911,31 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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}
|
{#if hoveredCol >= 0 && tableWidth > 0}
|
||||||
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||||
|
{@const colLeft = headerColWidth + hoveredCol * colWidth}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
|
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>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- "Now" column highlight. The current-time line itself is painted per
|
<!-- "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}
|
{#if nowLeftPx != null}
|
||||||
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||||
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
|
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
|
||||||
{#if nowIdx >= 0}
|
{#if nowIdx >= 0}
|
||||||
|
{@const colLeft = headerColWidth + nowIdx * colWidth}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5"
|
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>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -64,9 +64,13 @@
|
|||||||
if (val) onModelChange(val);
|
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
|
<Select.Trigger
|
||||||
aria-label={m.model_selector_aria({ label })}
|
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
|
<div
|
||||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
|
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
|
||||||
|
|||||||
Reference in New Issue
Block a user