beter crossfade

This commit is contained in:
Vincent van der Wal
2026-08-06 19:24:22 +02:00
parent f4a02a208c
commit 1c5ea2b52e
6 changed files with 276 additions and 199 deletions
+44
View File
@@ -0,0 +1,44 @@
import type { TransitionConfig } from 'svelte/transition';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* Fades a loading placeholder out *over* the content that replaces it.
*
* A plain `out:fade` is not usable on a skeleton: Svelte keeps the node in the
* layout for the length of the transition, so the real content stacks below the
* placeholder and the whole page jumps up the moment the placeholder finally
* unmounts. That is why these placeholders only ever faded in - the swap was
* abrupt, but at least nothing moved.
*
* This pins the placeholder to the box it already occupies and takes it out of
* flow for the fade instead. The content settles into its final position
* immediately and the skeleton dissolves on top of it.
*
* Requirements at the call site: the placeholder's parent must be positioned
* (`class="relative"`) and must be the same element that renders the real
* content, or the overlay lands somewhere else on the page.
*/
export function skeletonOut(node: HTMLElement, { duration = 260 } = {}): TransitionConfig {
if (prefersReducedMotion()) return { duration: 0 };
// measured while the node is still in flow, which is when Svelte builds the
// transition - the values below are what freeze it in place
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node;
return {
duration,
css: (t) => `
opacity: ${t};
position: absolute;
top: ${offsetTop}px;
left: ${offsetLeft}px;
width: ${offsetWidth}px;
height: ${offsetHeight}px;
margin: 0;
pointer-events: none;
z-index: 5;
`
};
}
@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { reportPageReady } from '$lib/stores/page-transition.svelte'; import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings'; import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state'; import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
@@ -342,42 +344,52 @@
</div> </div>
{/if} {/if}
{#if fetchedData} <!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within <div class="relative">
the page margins (padded), the graphs bleed to the edges --> {#if fetchedData}
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"> <!-- full-bleed graphs until lg / contained card on lg+; titles stay within
{#each chartDefs as def, i (i)} the page margins (padded), the graphs bleed to the edges -->
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}"> <div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
<div class="mb-1 px-3 lg:px-0"> {#each chartDefs as def, i (i)}
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4> <div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
{#if def.subtitle} <div class="mb-1 px-3 lg:px-0">
<p class="text-xs text-muted-foreground">{def.subtitle}</p> <h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
{/if} {#if def.subtitle}
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
{/if}
</div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
showCredit={def.showCredit}
zeroBaseLeft={def.zeroBaseLeft ?? true}
yMin={def.yMin}
yMax={def.yMax}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div> </div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}> {/each}
<CanvasChart </div>
bind:this={chartComponents[i]} {:else}
timestamps={timestampsSec} <!-- reserve the chart area height before data arrives (no layout shift) -->
timezone={fetchedData.timezone} <div in:fade={{ duration: 200 }} out:skeletonOut>
series={def.series} <ChartContainer
bands={fetchedData.daylightBands} loading
unit={def.unit} chartCount={params.hourly?.length || 1}
showCredit={def.showCredit} chartHeight={340}
zeroBaseLeft={def.zeroBaseLeft ?? true} bleed={false}
yMin={def.yMin} />
yMax={def.yMax} </div>
{showLegend} {/if}
height={300} </div>
group={CHART_GROUP}
/>
</ChartContainer>
</div>
{/each}
</div>
{:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
{/if}
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── --> <!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
@@ -9,6 +9,7 @@
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings'; import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date'; import { formatZoned } from '$lib/utils/date';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { listUnlessDefault, readList, syncSearchParams } from '$lib/utils/url-state'; import { listUnlessDefault, readList, syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
@@ -379,44 +380,49 @@
<!-- chart count derives from the selected variables (not the fetched data), <!-- chart count derives from the selected variables (not the fetched data),
so the reserved height is right even before the response arrives --> so the reserved height is right even before the response arrives -->
{#if fetchedData} <!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within <div class="relative">
the page margins (padded), the graphs bleed to the edges --> {#if fetchedData}
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border"> <!-- full-bleed graphs until lg / contained card on lg+; titles stay within
{#each chartDefs as def, i (i)} the page margins (padded), the graphs bleed to the edges -->
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}"> <div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
<div class="mb-1 px-3 lg:px-0"> {#each chartDefs as def, i (i)}
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4> <div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
{#if def.subtitle} <div class="mb-1 px-3 lg:px-0">
<p class="text-xs text-muted-foreground">{def.subtitle}</p> <h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
{/if} {#if def.subtitle}
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
{/if}
</div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div> </div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}> {/each}
<CanvasChart </div>
bind:this={chartComponents[i]} {:else}
timestamps={timestampsSec} <!-- reserve the chart area height before data arrives (no layout shift) -->
timezone={fetchedData.timezone} <div in:fade={{ duration: 200 }} out:skeletonOut>
series={def.series} <ChartContainer
bands={fetchedData.daylightBands} loading
unit={def.unit} chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
showCredit={def.showCredit} chartHeight={340}
{showLegend} bleed={false}
height={300} />
group={CHART_GROUP} </div>
/> {/if}
</ChartContainer> </div>
</div>
{/each}
</div>
{:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<ChartContainer
loading
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
chartHeight={340}
bleed={false}
/>
{/if}
{#if fetchedData && !loading} {#if fetchedData && !loading}
<ModelPictogramTimeline <ModelPictogramTimeline
@@ -13,6 +13,8 @@
storedVariablePrefs storedVariablePrefs
} from '$lib/stores/settings'; } from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer } from '$lib/components/charts'; import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
@@ -258,7 +260,7 @@
</div> </div>
{/if} {/if}
<div class="mt-4"> <div class="relative mt-4">
{#if result && fetchedHourly && fetchedDaily} {#if result && fetchedHourly && fetchedDaily}
<HistoricalDaily <HistoricalDaily
daily={result.daily} daily={result.daily}
@@ -282,7 +284,7 @@
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} /> <HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
{:else} {:else}
<div transition:fade={{ duration: 200 }} class="grid gap-3"> <div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div> <div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} /> <ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
</div> </div>
@@ -6,6 +6,8 @@
import { reportPageReady } from '$lib/stores/page-transition.svelte'; import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings'; import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
@@ -270,40 +272,44 @@
</div> </div>
{/if} {/if}
{#if visible && months.length > 0} <!-- `relative` anchors the placeholder while it fades out over the real
<SeasonalMonths {months} units={params} {normals} /> outlook rather than holding its own slot in the layout (skeletonOut). -->
<div class="relative">
{#if visible && months.length > 0}
<SeasonalMonths {months} units={params} {normals} />
<div class="mt-6"> <div class="mt-6">
<SeasonalCharts <SeasonalCharts
result={visible} result={visible}
{normals} {normals}
units={params} units={params}
{loading} {loading}
{showLegend} {showLegend}
bind:charts={chartComponents} bind:charts={chartComponents}
/> />
</div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
{#snippet controls()}
<div class="flex items-center gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none"
>{m.legend_show()}</Label
>
</div>
{/snippet}
</ChartToolbar>
</div>
{:else if !loadError}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
{/each}
</div> </div>
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
</div> <div class="mt-6 md:mt-10">
{/if} <ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
{#snippet controls()}
<div class="flex items-center gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none"
>{m.legend_show()}</Label
>
</div>
{/snippet}
</ChartToolbar>
</div>
{:else if !loadError}
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
{/each}
</div>
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
</div>
{/if}
</div>
</PaywallGate> </PaywallGate>
+98 -91
View File
@@ -18,6 +18,7 @@
import { formatZoned } from '$lib/utils/date'; import { formatZoned } from '$lib/utils/date';
import { daySwap, runDayTransition } from '$lib/utils/day-swap'; import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state'; import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer } from '$lib/components/charts'; import { ChartContainer } from '$lib/components/charts';
@@ -473,109 +474,115 @@
{locationRoute} {locationRoute}
/> />
{#if fetchedHourly && fetchedDaily} <!-- `relative` is what lets the placeholder fade out on top of the table
<div class="day-region-table" use:daySwap={selectedDayKey}> instead of holding a second slot in the layout (see skeletonOut). -->
<HourlyTable <div class="relative">
data={fetchedHourly} {#if fetchedHourly && fetchedDaily}
daily={fetchedDaily} <div class="day-region-table" use:daySwap={selectedDayKey}>
{selectedDay} <HourlyTable
units={params} data={fetchedHourly}
locationName={location.name ?? ''} daily={fetchedDaily}
onCustomize={() => (variableSidebarOpen = true)} {selectedDay}
/> units={params}
</div> locationName={location.name ?? ''}
{:else} onCustomize={() => (variableSidebarOpen = true)}
<!-- Mirrors the real table: same header bar and the same body height, />
so the heading doesn't pop in and nothing below moves. </div>
Placeholders only fade IN - a fade-out would keep them in the {:else}
layout while the real content mounts below, and everything below <!-- Mirrors the real table: same header bar and the same body height,
would jump the moment they finally unmount. --> so the heading doesn't pop in and nothing below moves. -->
<div
in:fade={{ duration: 200 }}
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
>
<div <div
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5" in:fade={{ duration: 200 }}
out:skeletonOut
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
> >
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div> <div
<div class="flex items-center gap-2"> class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
<div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div> >
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div> <div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
</div> <div class="flex items-center gap-2">
</div> <div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div>
<!-- one placeholder per row the real table will render, so the body <div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
reads as a loading table rather than a blank panel -->
<div class="divide-y divide-border/50">
<!-- the time + daylight row, which is taller than the variable rows -->
<div class="flex items-center gap-4 px-4" style="height: {TABLE_TIME_ROW_PX}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div>
{#each { length: enabledTableRows } as _, i (i)}
<div class="flex items-center gap-4 px-4" style="height: {tableRowPx}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div> </div>
{/each} </div>
<!-- one placeholder per row the real table will render, so the body
reads as a loading table rather than a blank panel -->
<div class="divide-y divide-border/50">
<!-- the time + daylight row, which is taller than the variable rows -->
<div class="flex items-center gap-4 px-4" style="height: {TABLE_TIME_ROW_PX}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div>
{#each { length: enabledTableRows } as _, i (i)}
<div class="flex items-center gap-4 px-4" style="height: {tableRowPx}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div>
{/each}
</div>
</div> </div>
</div> {/if}
{/if} </div>
{#if fetchedHourly && fetchedDaily} <div class="relative">
<div class="day-region-summary" use:daySwap={selectedDayKey}> {#if fetchedHourly && fetchedDaily}
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} /> <div class="day-region-summary" use:daySwap={selectedDayKey}>
</div> <DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
{:else} </div>
<!-- Same footprint as the written forecast, so it doesn't shove the {:else}
meteograms down when it arrives. The heights are measured from the <!-- Same footprint as the written forecast, so it doesn't shove the
real card at each breakpoint: on phones the narrative is clamped to meteograms down when it arrives. The heights are measured from the
five lines with a fixed toggle row, so that side is exact; from md real card at each breakpoint: on phones the narrative is clamped to
up the text runs free and this is the typical height. The sun/moon five lines with a fixed toggle row, so that side is exact; from md
grid reflows at sm, md and lg, which is why all four are needed. --> up the text runs free and this is the typical height. The sun/moon
<section class="mt-6" in:fade={{ duration: 200 }}> grid reflows at sm, md and lg, which is why all four are needed. -->
<div <section class="mt-6" in:fade={{ duration: 200 }} out:skeletonOut>
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56" <div
></div> class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
</section> ></div>
{/if} </section>
{/if}
</div>
{#if fetchedHourly} <div class="relative">
<div class="day-region-charts" use:daySwap={selectedDayKey}> {#if fetchedHourly}
<MeteogramCharts <div class="day-region-charts" use:daySwap={selectedDayKey}>
data={fetchedHourly} <MeteogramCharts
{selectedDay} data={fetchedHourly}
units={params} {selectedDay}
{loading} units={params}
{chartHeight} {loading}
/> {chartHeight}
</div> />
{:else} </div>
<!-- reserve the exact chart area height before the first fetch resolves, {:else}
header row included --> <!-- reserve the exact chart area height before the first fetch resolves,
<section class="mt-8" in:fade={{ duration: 200 }}> header row included -->
<!-- The real header wraps to two rows until the controls fit beside <section class="mt-8" in:fade={{ duration: 200 }} out:skeletonOut>
<!-- The real header wraps to two rows until the controls fit beside
the title, which happens at different widths than you would the title, which happens at different widths than you would
expect because the sidebar takes its share from md up. These expect because the sidebar takes its share from md up. These
min-heights follow the measured wrap points. --> min-heights follow the measured wrap points. -->
<div <div
class="mb-3 flex min-h-27 flex-wrap items-center justify-between gap-2 sm:min-h-16.5 md:min-h-27 lg:min-h-16.5 xl:min-h-7.5" class="mb-3 flex min-h-27 flex-wrap items-center justify-between gap-2 sm:min-h-16.5 md:min-h-27 lg:min-h-16.5 xl:min-h-7.5"
> >
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div> <div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div> <div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div> <div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
</div>
</div> </div>
</div> <!-- Each meteogram is its plot plus a title row and axis labels, so
<!-- Each meteogram is its plot plus a title row and axis labels, so
the reserved box needs that chrome on top of the plot height or the reserved box needs that chrome on top of the plot height or
everything below lands ~50px per chart too high. --> everything below lands ~50px per chart too high. -->
<ChartContainer <ChartContainer
loading loading
chartCount={enabledChartCount || 1} chartCount={enabledChartCount || 1}
chartHeight={chartHeight + (narrowViewport ? 43 : 62)} chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
/> />
</section> </section>
{/if} {/if}
</div>
{#if selectedDayKey} {#if selectedDayKey}
<NearbyCities <NearbyCities