crossfade

This commit is contained in:
Vincent van der Wal
2026-08-01 15:58:19 +02:00
parent 0d5dbbc61f
commit 17cf8df109
16 changed files with 258 additions and 51 deletions
+55 -11
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { tick } from 'svelte';
import { get } from 'svelte/store';
import { fade, fly } from 'svelte/transition';
import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
import { storedTheme } from '$lib/stores/settings';
import Footer from '$lib/components/navigation/footer.svelte';
@@ -15,8 +17,6 @@
import { routePath } from '$lib/i18n';
import { pageContentReady } from '$lib/stores/page-transition.svelte';
import './layout.css';
let { children } = $props();
@@ -61,13 +61,16 @@
});
// ── Page cross-fade ───────────────────────────────────────────────────────
// The weather pages fetch their forecast after the route swap, so tying the
// fade to navigation alone would cross-fade one skeleton into another and
// then cut hard to the real content. Instead the outgoing page fades out on
// navigation and the incoming one fades in once it reports that its data has
// landed - with a ceiling so a slow or silent page is never left hidden.
// A real cross-fade needs the outgoing page still on screen while the
// incoming one appears - so the view transition is opened at navigation and
// deliberately held open until the new page reports that its data has landed.
// The browser keeps showing the old snapshot for that whole time (rather than
// flashing a skeleton), then fades it into the finished page.
//
// The wait is capped: past the ceiling we cross-fade into whatever is on
// screen instead of freezing the UI on a slow network.
const READY_CEILING_MS = 2200;
const FADE_OUT_MS = 170;
const REVEAL_CEILING_MS = 2500;
let contentVisible = $state(true);
let revealTimer = 0;
@@ -75,21 +78,62 @@
const reducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
onNavigate(() => {
// Routes that fetch their own forecast after mounting. Knowing this up front
// is what makes the wait reliable: the layout clears the ready flag before
// the swap rather than trusting the incoming page to have done it.
const DATA_ROUTES = new Set([
'/weather/week/[location]',
'/weather/14-day/[location]',
'/weather/compare/[location]',
'/weather/seasonal/[location]',
'/weather/historical/[location]'
]);
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
async function waitForContent(): Promise<void> {
const deadline = Date.now() + READY_CEILING_MS;
while (!get(pageContentReady) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 40));
}
// one more frame so the page paints its data before the snapshot is taken
await tick();
}
onNavigate((navigation) => {
if (reducedMotion()) return;
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
if (loadsData) markPageLoading();
if (typeof document.startViewTransition === 'function') {
return new Promise<void>((swap) => {
document.startViewTransition(async () => {
// hand control back so SvelteKit swaps the DOM underneath the
// frozen snapshot of the old page
swap();
// A superseded navigation (a redirect, or a fast second click)
// rejects this promise; that is not an error worth surfacing,
// and leaving it unhandled shows up as "navigation aborted".
await navigation.complete.catch(() => {});
if (loadsData) await waitForContent();
});
});
}
// No view transitions: fall back to fading out, then back in on arrival.
clearTimeout(revealTimer);
contentVisible = false;
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
});
afterNavigate(() => {
if (reducedMotion()) {
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
contentVisible = true;
return;
}
const startedAt = Date.now();
const reveal = () => {
if (get(pageContentReady) || Date.now() - startedAt > REVEAL_CEILING_MS) {
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
contentVisible = true;
return;
}
+23 -1
View File
@@ -165,6 +165,26 @@
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. */
.daystrip {
view-transition-name: daystrip;
}
::view-transition-group(daystrip) {
z-index: 20;
}
/* Same for the topbar: a captured region's snapshot is painted at its layout
position, including the part normally scrolled up behind the chrome. */
.topbar {
view-transition-name: topbar;
}
::view-transition-group(topbar) {
z-index: 30;
}
::view-transition-old(day-table),
::view-transition-new(day-table),
::view-transition-old(day-summary),
@@ -183,7 +203,9 @@
@media (prefers-reduced-motion: reduce) {
.day-region-table,
.day-region-summary,
.day-region-charts {
.day-region-charts,
.daystrip,
.topbar {
view-transition-name: none;
}
}
@@ -4,9 +4,8 @@
import { page } from '$app/stores';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { syncSearchParams } from '$lib/utils/url-state';
@@ -5,9 +5,8 @@
import { page } from '$app/stores';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
import { readList, syncSearchParams } from '$lib/utils/url-state';
@@ -3,6 +3,7 @@
import { SvelteDate } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import {
storedChartLayout,
storedLocation,
@@ -10,8 +11,6 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { ChartContainer } from '$lib/components/charts';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
@@ -2,9 +2,8 @@
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { storedLocation, storedUnits } from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedUnits } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
@@ -6,6 +6,7 @@
import { page } from '$app/stores';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import {
storedChartLayout,
storedLocation,
@@ -14,8 +15,6 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { formatZoned } from '$lib/utils/date';
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location';
@@ -66,6 +65,19 @@
let variableSidebarOpen = $state(false);
// Meteogram canvases are shorter on phones, where a 300px plot eats most of
// the viewport. The placeholder below uses the same number, so the reserved
// space still matches exactly.
let narrowViewport = $state(false);
onMount(() => {
const mq = window.matchMedia('(max-width: 767px)');
const apply = () => (narrowViewport = mq.matches);
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
let chartHeight = $derived(narrowViewport ? 215 : 300);
// Number of meteogram panels: reserves the chart area height before data
// arrives (no layout shift)
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
@@ -418,7 +430,13 @@
{#if fetchedHourly}
<div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
<MeteogramCharts
data={fetchedHourly}
{selectedDay}
units={params}
{loading}
{chartHeight}
/>
</div>
{:else}
<!-- reserve the exact chart area height before the first fetch resolves,
@@ -431,7 +449,7 @@
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
</div>
</div>
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
</section>
{/if}
</div>
@@ -443,7 +443,7 @@
--icon-full: 44px;
/* the compact icon carries the square tile: sized so the content fills it
and the leftover slack under the temps stays small */
--icon-min: 27px;
--icon-min: 31px;
--pt-full: 7px;
--pt-min: 2px;
--gap-full: 8px;
@@ -518,7 +518,7 @@
--pb-full: 10px;
--pb-min: 3px;
--icon-lift: 0px;
--collapse-extra: 60px;
--collapse-extra: 0px;
}
.daystrip {
/* breathing room between the full cards and the table */
@@ -526,6 +526,24 @@
}
}
/* Desktop only: the docked tiles get roughly 1.75x bigger, with the icon and
type scaled to match so they stay in proportion. Phones and tablets keep
the compact bar - there the screen width is the scarce resource. */
@media (min-width: 1024px) {
.sentinel,
.daystrip {
--cell-h-min: 86px;
--icon-min: 47px;
--dow-font-min: 11px;
--tmax-font-min: 14px;
--tmin-font-min: 12px;
--tmax-padx-min: 6px;
--pt-min: 5px;
--pb-min: 6px;
--icon-lift: 0px;
}
}
/* Invisible collapse band: the scroll distance over which the collapse
plays. The negative margin removes it from layout so nothing shifts. */
.sentinel {
@@ -99,7 +99,10 @@
</script>
<section class="mt-6" aria-label={m.summary_heading()}>
<div class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<!-- flush with the screen edges on phones, a contained card from md up -->
<div
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
>
<div class="border-b border-border/70 bg-muted/40 px-4 py-2.5">
<h3 class="text-base font-bold">
{formatZoned(selectedDay, timezone, 'EEEE')}
@@ -458,7 +458,9 @@
1h needs far more room than 3h (24 vs 8 columns) -->
<div class="overflow-x-auto" bind:this={tableScrollEl} onscroll={onTableScroll}>
<div
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
class="relative {is3h
? 'min-w-[400px] md:min-w-[560px]'
: 'min-w-[780px] md:min-w-[1100px]'}"
bind:clientWidth={tableWidth}
>
<table class="w-full table-fixed border-collapse whitespace-nowrap">
@@ -885,13 +887,34 @@
}
/* ── Responsive ─────────────────────────────────────────────── */
/* Phones: the table is the tallest thing on the page, so every cell is taken
down to roughly 0.7 of its desktop size - padding, type and the icons that
actually set the row height. Desktop keeps its original metrics. */
@media (max-width: 768px) {
.hdr {
padding: 3px 2px;
font-size: 10px;
padding: 2px 1px;
font-size: 9px;
}
.cell {
font-size: 11px;
padding: 1px;
font-size: 10px;
}
/* The row-header icon and the fixed cell height are what actually set the
row height, so both come down explicitly. */
.cell :global(svg),
.hdr :global(svg) {
width: 22px;
height: 22px;
}
.cell {
height: 34px;
}
.hdr :global(span) {
font-size: 9px;
line-height: 1.15;
}
.precip-bar {
min-height: 2px;
}
}
</style>
@@ -25,14 +25,15 @@
selectedDay: Date;
units: WeatherUnits;
loading: boolean;
/** Canvas height in px; the page shrinks it on phones. */
chartHeight?: number;
onResetZoom?: () => void;
}
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
let { data, selectedDay, units, loading, chartHeight = 300, onResetZoom }: Props = $props();
const CHART_GROUP = 'week-meteogram';
const SECONDS_PER_DAY = 24 * 3600;
const CHART_HEIGHT = 300;
let customizerOpen = $state(false);
let downloadingPng = $state(false);
@@ -351,13 +352,7 @@
<span class="lg:hidden">{panel.titleShort}</span>
</h4>
</div>
<ChartContainer
{loading}
chartCount={1}
chartHeight={CHART_HEIGHT}
minWidth={520}
bleed={false}
>
<ChartContainer {loading} chartCount={1} {chartHeight} minWidth={520} bleed={false}>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
@@ -379,7 +374,7 @@
yMaxRight={panel.def.yMaxRight}
showCredit={i === renderPanels.length - 1}
showLegend
height={CHART_HEIGHT}
height={chartHeight}
group={CHART_GROUP}
/>
</ChartContainer>