diff --git a/messages/de.json b/messages/de.json index f33792c..e89e6ed 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", @@ -269,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", @@ -282,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_timeline_title": "Zeitleiste des Modellvergleichs", "compare_model_label": "Modell", diff --git a/messages/en.json b/messages/en.json index 86c2c41..f96853e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", @@ -269,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", @@ -282,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_timeline_title": "Model Comparison Timeline", "compare_model_label": "Model", diff --git a/messages/es.json b/messages/es.json index d6e6270..f0f4376 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", @@ -269,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", @@ -282,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_timeline_title": "Cronología de comparación de modelos", "compare_model_label": "Modelo", diff --git a/messages/fr.json b/messages/fr.json index 2414181..1a4be7c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", @@ -269,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", @@ -282,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_timeline_title": "Chronologie de comparaison des modèles", "compare_model_label": "Modèle", diff --git a/messages/it.json b/messages/it.json index b25c22b..75930c5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", @@ -269,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", @@ -282,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_timeline_title": "Cronologia del confronto tra modelli", "compare_model_label": "Modello", diff --git a/src/lib/components/navigation/header.svelte b/src/lib/components/navigation/header.svelte index 2387b5a..32c9cca 100644 --- a/src/lib/components/navigation/header.svelte +++ b/src/lib/components/navigation/header.svelte @@ -45,8 +45,16 @@ // 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, locationRegion].filter(Boolean).join(' · ')); + let locationLine = $derived([location?.name, locationDetail].filter(Boolean).join(' · ')); const themeCycle: Theme[] = ['system', 'light', 'dark']; const themeTitles: Record string> = { @@ -120,8 +128,8 @@ {location.name} - {#if locationRegion} - · {locationRegion} + {#if locationDetail} + · {locationDetail} {/if} diff --git a/src/lib/stores/page-transition.svelte.ts b/src/lib/stores/page-transition.svelte.ts index 8563a8e..3a2a558 100644 --- a/src/lib/stores/page-transition.svelte.ts +++ b/src/lib/stores/page-transition.svelte.ts @@ -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. * diff --git a/src/lib/utils/day-swap.ts b/src/lib/utils/day-swap.ts index ea6aad1..7290fcd 100644 --- a/src/lib/utils/day-swap.ts +++ b/src/lib/utils/day-swap.ts @@ -3,6 +3,7 @@ import { tick } from 'svelte'; import { canStartViewTransition, prefersReducedMotion, + skipActiveViewTransition, startViewTransition, supportsViewTransitions } from './view-transition'; @@ -21,22 +22,53 @@ import { * 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 { if (!canStartViewTransition()) { update(); return; } - // 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 for us when it ends. - await startViewTransition( - async () => { - update(); - await tick(); - }, - { rootClass: 'day-switch' } - ); + const token = ++dayTransitionToken; + const root = document.documentElement; + + // The region snapshots include the part of the table normally scrolled up + // behind the sticky strip; the transition overlay is clipped at the bar's + // bottom edge so they cannot paint over the (live, clickable) strip. + // Measured per switch because the bar's height follows the scroll collapse. + const bar = document.querySelector('.daystrip .strip-row'); + if (bar) { + const clip = Math.max(0, bar.getBoundingClientRect().bottom); + root.style.setProperty('--day-switch-clip', `${clip}px`); + } + + // The page stays scrollable during the fade, but the snapshots and the clip + // line above are anchored to where things were at capture - so the first + // sign of scrolling finishes the fade on the spot instead of animating + // against a moving page. + const skip = () => skipActiveViewTransition(); + window.addEventListener('wheel', skip, { passive: true }); + window.addEventListener('touchmove', skip, { passive: true }); + + try { + // Svelte applies the change on the next tick; the transition has to wait + // for that before it snapshots the new state. `day-switch` scopes which + // regions take part (see routes/layout.css) and is cleared when it ends. + await startViewTransition( + async () => { + update(); + await tick(); + }, + { rootClass: 'day-switch' } + ); + } finally { + window.removeEventListener('wheel', skip); + window.removeEventListener('touchmove', skip); + // a newer switch owns the clip var now; only the last one may clear it + if (token === dayTransitionToken) root.style.removeProperty('--day-switch-clip'); + } } /** diff --git a/src/lib/utils/view-transition.ts b/src/lib/utils/view-transition.ts index 6045f3b..45f3f3b 100644 --- a/src/lib/utils/view-transition.ts +++ b/src/lib/utils/view-transition.ts @@ -42,6 +42,29 @@ interface Options { /** Set while a transition holds the screen frozen on the outgoing snapshot. */ let capturing: Promise | 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(); + +/** 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'; @@ -76,9 +99,13 @@ export function startViewTransition(update: UpdateCallback, options: Options = { } 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); + active = transition; // A throw inside the callback rejects `updateCallbackDone` as well as // `finished`. Nothing awaits the former, and an unhandled rejection there is @@ -102,6 +129,14 @@ export function startViewTransition(update: UpdateCallback, options: Options = { () => {} ) .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); + } }); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c604f33..73c90a8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import { page } from '$app/stores'; import { + mapTransitionCover, markPageLoading, markPageReady, pageContentReady @@ -104,10 +105,14 @@ const OVERLAY_CEILING_MS = 15000; // 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 - // animates a hole where the map is: leaving it, the map drops out at frame one - // and that blank sits under the incoming page for the whole run. Swapping - // outright is the honest answer - there is nothing here to cross-fade. + // 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); @@ -187,7 +192,7 @@ /** * 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. * * 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 ?? {}); } - onNavigate((navigation) => { + 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 @@ -217,9 +222,16 @@ // `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 touchesMap = - navigation.from?.route?.id === MAPS_ROUTE || navigation.to?.route?.id === MAPS_ROUTE; - const underTransition = canStartViewTransition() && !touchesMap; + const underTransition = canStartViewTransition(); + + // Leaving the maps page: cover the iframe before the outgoing state is + // captured, so the snapshot holds a clean panel instead of a hole where + // the map was. The tick makes sure the cover is actually in the DOM by + // the time the capture reads it. + if (underTransition && navigation.from?.route?.id === MAPS_ROUTE) { + mapTransitionCover.set(true); + await tick(); + } return new Promise((swap) => { void startViewTransition( @@ -242,6 +254,11 @@ let mainEl = $state(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
, 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. diff --git a/src/routes/layout.css b/src/routes/layout.css index 967201a..918fca6 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -135,9 +135,18 @@ /* ── 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; } @@ -148,23 +157,20 @@ 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 and the sidebar: a captured region's snapshot is - painted at its layout position, including the part normally scrolled up - behind the chrome. Both are also identical either side of a *page* swap, - 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, + /* 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; } @@ -178,24 +184,27 @@ 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) { .day-region-table, .day-region-summary, .day-region-charts, - .daystrip, .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 @@ -240,21 +249,15 @@ mix-blend-mode: plus-lighter; } - /* Chrome captured only so the fading regions cannot paint over it. The strip - does change - the selected day moves - but that change belongs to the - 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. */ + /* Pinned chrome swaps outright: identical on both sides, so any animation + would only risk a flicker. */ ::view-transition-old(topbar), - ::view-transition-old(sidebar), - ::view-transition-old(daystrip) { + ::view-transition-old(sidebar) { animation: none; opacity: 0; } ::view-transition-new(topbar), - ::view-transition-new(sidebar), - ::view-transition-new(daystrip) { + ::view-transition-new(sidebar) { animation: none; opacity: 1; } diff --git a/src/routes/weather/+layout.svelte b/src/routes/weather/+layout.svelte index 518ae36..a777a62 100644 --- a/src/routes/weather/+layout.svelte +++ b/src/routes/weather/+layout.svelte @@ -45,8 +45,18 @@ // 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". - let region = $derived([location?.admin1, location?.country].filter(Boolean).join(', ')); + // "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(', ') + ); {#if subtitle && location} @@ -60,11 +70,11 @@

{location.name} + · {subtitle}

-

- {#if region}{region}·{/if}{subtitle} -

+ {#if region} +

{region}

+ {/if}
diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index 3235980..a4be3c6 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -298,7 +298,7 @@ {#snippet heroActions()} -
+
chart != null)); - // ─── Zoom range controls (mirrors the 7-day meteograms) ───────────────────── + // ─── Zoom range controls (mirrors the week-page meteograms) ───────────────────── const SECONDS_PER_DAY = 24 * 3600; @@ -321,9 +321,62 @@ }); - + {#snippet heroActions()} - + +
+ +
+ +