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
+98 -91
View File
@@ -18,6 +18,7 @@
import { formatZoned } from '$lib/utils/date';
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer } from '$lib/components/charts';
@@ -473,109 +474,115 @@
{locationRoute}
/>
{#if fetchedHourly && fetchedDaily}
<div class="day-region-table" use:daySwap={selectedDayKey}>
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
/>
</div>
{:else}
<!-- Mirrors the real table: same header bar and the same body height,
so the heading doesn't pop in and nothing below moves.
Placeholders only fade IN - a fade-out would keep them in the
layout while the real content mounts below, and everything below
would jump the moment they finally unmount. -->
<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"
>
<!-- `relative` is what lets the placeholder fade out on top of the table
instead of holding a second slot in the layout (see skeletonOut). -->
<div class="relative">
{#if fetchedHourly && fetchedDaily}
<div class="day-region-table" use:daySwap={selectedDayKey}>
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
/>
</div>
{:else}
<!-- Mirrors the real table: same header bar and the same body height,
so the heading doesn't pop in and nothing below moves. -->
<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 class="flex items-center gap-2">
<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>
</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
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
>
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
<div class="flex items-center gap-2">
<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>
{/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>
{/if}
{/if}
</div>
{#if fetchedHourly && fetchedDaily}
<div class="day-region-summary" use:daySwap={selectedDayKey}>
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
</div>
{:else}
<!-- Same footprint as the written forecast, so it doesn't shove the
meteograms down when it arrives. The heights are measured from the
real card at each breakpoint: on phones the narrative is clamped to
five lines with a fixed toggle row, so that side is exact; from md
up the text runs free and this is the typical height. The sun/moon
grid reflows at sm, md and lg, which is why all four are needed. -->
<section class="mt-6" in:fade={{ duration: 200 }}>
<div
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
></div>
</section>
{/if}
<div class="relative">
{#if fetchedHourly && fetchedDaily}
<div class="day-region-summary" use:daySwap={selectedDayKey}>
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
</div>
{:else}
<!-- Same footprint as the written forecast, so it doesn't shove the
meteograms down when it arrives. The heights are measured from the
real card at each breakpoint: on phones the narrative is clamped to
five lines with a fixed toggle row, so that side is exact; from md
up the text runs free and this is the typical height. The sun/moon
grid reflows at sm, md and lg, which is why all four are needed. -->
<section class="mt-6" in:fade={{ duration: 200 }} out:skeletonOut>
<div
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
></div>
</section>
{/if}
</div>
{#if fetchedHourly}
<div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts
data={fetchedHourly}
{selectedDay}
units={params}
{loading}
{chartHeight}
/>
</div>
{:else}
<!-- reserve the exact chart area height before the first fetch resolves,
header row included -->
<section class="mt-8" in:fade={{ duration: 200 }}>
<!-- The real header wraps to two rows until the controls fit beside
<div class="relative">
{#if fetchedHourly}
<div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts
data={fetchedHourly}
{selectedDay}
units={params}
{loading}
{chartHeight}
/>
</div>
{:else}
<!-- reserve the exact chart area height before the first fetch resolves,
header row included -->
<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
expect because the sidebar takes its share from md up. These
min-heights follow the measured wrap points. -->
<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"
>
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
<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-24 animate-pulse rounded-lg bg-muted"></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"
>
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
<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-24 animate-pulse rounded-lg bg-muted"></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
everything below lands ~50px per chart too high. -->
<ChartContainer
loading
chartCount={enabledChartCount || 1}
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
/>
</section>
{/if}
<ChartContainer
loading
chartCount={enabledChartCount || 1}
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
/>
</section>
{/if}
</div>
{#if selectedDayKey}
<NearbyCities