finish animations and add elevation
This commit is contained in:
+4
-3
@@ -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",
|
||||
|
||||
+4
-3
@@ -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",
|
||||
|
||||
+8
-7
@@ -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",
|
||||
|
||||
+8
-7
@@ -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",
|
||||
|
||||
+8
-7
@@ -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",
|
||||
|
||||
@@ -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<Theme, () => string> = {
|
||||
@@ -120,8 +128,8 @@
|
||||
<!-- 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}>
|
||||
{location.name}
|
||||
{#if locationRegion}
|
||||
<span class="font-normal text-muted-foreground">· {locationRegion}</span>
|
||||
{#if locationDetail}
|
||||
<span class="font-normal text-muted-foreground">· {locationDetail}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tick } from 'svelte';
|
||||
import {
|
||||
canStartViewTransition,
|
||||
prefersReducedMotion,
|
||||
skipActiveViewTransition,
|
||||
startViewTransition,
|
||||
supportsViewTransitions
|
||||
} from './view-transition';
|
||||
@@ -21,15 +22,40 @@ 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<void> {
|
||||
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.
|
||||
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();
|
||||
@@ -37,6 +63,12 @@ export async function runDayTransition(update: () => void): Promise<void> {
|
||||
},
|
||||
{ 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. */
|
||||
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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void>((swap) => {
|
||||
void startViewTransition(
|
||||
@@ -242,6 +254,11 @@
|
||||
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.
|
||||
|
||||
+39
-36
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(', ')
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if subtitle && location}
|
||||
@@ -60,11 +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">
|
||||
{#if region}<span class="lg:hidden">{region}<span class="mx-1 opacity-50">·</span></span
|
||||
>{/if}{subtitle}
|
||||
</p>
|
||||
{#if region}
|
||||
<p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
|
||||
<!-- 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] ?? DEFAULT_MODEL}
|
||||
groups={ensembleModelGroups}
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
// components persist across refetches; entries are null while unmounted
|
||||
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;
|
||||
|
||||
@@ -321,9 +321,62 @@
|
||||
});
|
||||
</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()}
|
||||
<!-- 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">
|
||||
<span class="hidden text-xs text-muted-foreground lg:inline">
|
||||
{m.meteograms_zoom_hint()}
|
||||
|
||||
@@ -225,7 +225,7 @@
|
||||
|
||||
<!-- 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}
|
||||
|
||||
@@ -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)))
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
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 { mapsDomainForModel } from '$lib/utils/maps-domain';
|
||||
@@ -27,6 +28,14 @@
|
||||
// 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);
|
||||
};
|
||||
@@ -93,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) -->
|
||||
@@ -107,6 +116,20 @@
|
||||
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>
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
<!-- 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 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
|
||||
forecast is on its way they stay mounted but invisible, because
|
||||
mounting them on arrival re-flowed the row and nudged the heading.
|
||||
|
||||
@@ -346,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) => {
|
||||
|
||||
@@ -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)
|
||||
@@ -608,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>
|
||||
@@ -904,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"
|
||||
|
||||
Reference in New Issue
Block a user