layout shifts

This commit is contained in:
Vincent van der Wal
2026-08-01 13:37:04 +02:00
parent 792da49cac
commit fe961a27ae
24 changed files with 427 additions and 210 deletions
+70 -56
View File
@@ -23,6 +23,7 @@
humanizeWeatherError
} from '$lib/services/weather';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters } from '../../options';
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
import DailyStripSticky from './DailyStripSticky.svelte';
@@ -37,6 +38,8 @@
let { data }: { data: PageData } = $props();
useHeroActions(heroActions);
let params = $state({
models: ['best_match'],
...defaultParameters
@@ -56,6 +59,14 @@
// arrives (no layout shift)
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
// Height the hourly table will occupy once it renders, so its placeholder
// reserves exactly that and nothing below it jumps. The table is a header row
// plus one row per enabled variable, so the count drives the estimate.
const TABLE_HEADER_PX = 96;
const TABLE_ROW_PX = 57;
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
let tableSkeletonHeight = $derived(TABLE_HEADER_PX + enabledTableRows * TABLE_ROW_PX);
// Request only the hourly variables the table rows and meteograms actually
// show, so unused variables are never fetched. weather_code is always
// included: the day cards / strip derive their day- and night-period icons
@@ -200,45 +211,24 @@
<meta name="description" content="7-day 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">
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
storedModel.set(model);
// a new model may not support the extended / past range
forecastDays = 7;
pastDays = 0;
}}
/>
</div>
{/snippet}
<div class="week-page">
<div class="weather-content" style="min-height: 50vh">
<!-- Page hero: prominent location + weather model selection -->
<div class="relative flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="flex min-w-0 items-center gap-3">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country ?? ''}
/>
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
</h1>
<p class="truncate text-sm text-muted-foreground">
<span class="lg:hidden"
>{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>7-day forecast
</p>
</div>
</div>
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
storedModel.set(model);
// a new model may not support the extended / past range
forecastDays = 7;
pastDays = 0;
}}
/>
</div>
</div>
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
{#if loadError}
@@ -314,19 +304,20 @@
the strip's sentinel view-timeline so the sticky strip (a sibling of
the sentinel) can scrub its collapse from it. -->
<div style="timeline-scope: --daystrip-sentinel">
{#if fetchedDaily}
<DailyStripSticky
daily={fetchedDaily}
{selectedDay}
units={params}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
{locationRoute}
/>
{/if}
<!-- rendered even before the data lands: the strip's box is a fixed
height, so keeping it mounted reserves its space (it shows skeleton
tiles meanwhile) instead of shoving the page down on arrival -->
<DailyStripSticky
daily={fetchedDaily}
{selectedDay}
units={params}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
{locationRoute}
/>
{#if fetchedHourly && fetchedDaily}
<HourlyTable
@@ -338,18 +329,41 @@
onCustomize={() => (variableSidebarOpen = true)}
/>
{:else}
<!-- placeholder with the table's approximate height: no layout shift -->
<!-- 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
transition:fade={{ duration: 200 }}
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
></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
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>
</div>
<div class="animate-pulse bg-card" style="height: {tableSkeletonHeight}px"></div>
</div>
{/if}
{#if fetchedHourly}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
{:else}
<!-- reserve the exact chart area height before the first fetch resolves -->
<section class="mt-8" transition:fade={{ duration: 200 }}>
<!-- reserve the exact chart area height before the first fetch resolves,
header row included -->
<section class="mt-8" in:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<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>
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
</section>
{/if}
@@ -1,7 +1,13 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
import { type ChartPanel, defaultChartLayout, storedChartLayout } from '$lib/stores/settings';
import {
type ChartPanel,
type ChartRangePref,
defaultChartLayout,
storedChartLayout,
storedChartRange
} from '$lib/stores/settings';
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
@@ -14,6 +20,14 @@
// Variables not placed in any panel form the "available" pool.
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
const RANGE_OPTIONS: { value: ChartRangePref; label: string; hint: string }[] = [
{ value: 'auto', label: 'Auto', hint: '3 days on phones, everything on wider screens' },
{ value: 'today', label: 'Today', hint: 'Open on the current day' },
{ value: '3d', label: '3 days', hint: 'Open on the next three days' },
{ value: '5d', label: '5 days', hint: 'Open on the next five days' },
{ value: 'all', label: 'All', hint: 'Open on the full forecast' }
];
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
// ─── Drag state ─────────────────────────────────────────────────────────────
@@ -159,6 +173,34 @@
</div>
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
<!-- Which slice of the forecast the meteograms open on -->
<div class="rounded-xl border border-border p-3">
<div class="mb-2 flex items-baseline justify-between gap-2">
<span class="text-[11px] font-bold tracking-wider text-primary uppercase">
Default time range
</span>
<span class="text-[11px] text-muted-foreground">
{RANGE_OPTIONS.find((o) => o.value === $storedChartRange)?.hint}
</span>
</div>
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
{#each RANGE_OPTIONS as option (option.value)}
{@const active = $storedChartRange === option.value}
<button
type="button"
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold whitespace-nowrap transition-colors {active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={active}
title={option.hint}
onclick={() => storedChartRange.set(option.value)}
>
{option.label}
</button>
{/each}
</div>
</div>
{#each $storedChartLayout as panel, i (panel.id)}
<div
data-zone={panel.id}
@@ -44,6 +44,10 @@
locationRoute
}: Props = $props();
// enough placeholder tiles to fill a wide viewport; the row scrolls, so a
// couple too many costs nothing
const SKELETON_CELLS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
// All sizing derives from a single registered custom property `--strip-p`
// (0 = full cards, 1 = compact strip). Mobile and desktop share the exact
@@ -308,7 +312,7 @@
class="flex w-full flex-col items-center gap-0.5 md:flex-row md:justify-center md:gap-2"
>
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
<svg class="fill-sky-500" width="13" height="13">
<svg class="fill-sky-500" width="16" height="16">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
@@ -319,16 +323,16 @@
class="hidden shrink-0 md:inline-flex md:-mr-1"
style="transform: {getWindArrowRotation(windDir)}"
>
<svg class="fill-muted-foreground" width="20" height="20">
<svg class="fill-muted-foreground" width="25" height="25">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
></use>
</svg>
</span>
<svg class="fill-muted-foreground md:hidden" width="13" height="13">
<svg class="fill-muted-foreground md:hidden" width="16" height="16">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{:else}
<svg class="fill-muted-foreground" width="13" height="13">
<svg class="fill-muted-foreground" width="16" height="16">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{/if}
@@ -391,6 +395,16 @@
</a>
{/if}
</div>
{:else}
<!-- same cell metrics as the real strip, so the bar looks alive while
the forecast lands and nothing moves when it does -->
<div class="strip-days flex">
{#each SKELETON_CELLS as i (i)}
<div
class="strip-cell shrink-0 animate-pulse rounded-xl border border-border/50 bg-muted/60"
></div>
{/each}
</div>
{/if}
</div>
</div>
@@ -1,7 +1,12 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
import {
type ChartPanel,
type ChartRangePref,
storedChartLayout,
storedChartRange
} from '$lib/stores/settings';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
@@ -86,11 +91,47 @@
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
}
function resetZoom(): void {
/** Drop any zoom and show the whole forecast. */
function showFullRange(): void {
liveCharts[0]?.resetRange();
onResetZoom?.();
}
/** Back to the range the meteograms are configured to open on. */
function resetZoom(): void {
applyDefaultRange();
onResetZoom?.();
}
// ─── Default range ──────────────────────────────────────────────────────────
/** Days the preference resolves to, or null for the full range. */
function rangeDaysFor(pref: ChartRangePref): number | null {
if (pref === 'today') return 1;
if (pref === '3d') return 3;
if (pref === '5d') return 5;
if (pref === 'all') return null;
// auto: a week of hourly data is unreadable on a phone-width plot
const phone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
return phone ? 3 : null;
}
function applyDefaultRange(): void {
const days = rangeDaysFor($storedChartRange);
if (days == null) liveCharts[0]?.resetRange();
else setRangeDays(new Date(), days);
}
// Applied once the charts exist, and again whenever the preference changes -
// but never after that, so it can't fight a zoom the user just set by hand.
let appliedPref: ChartRangePref | null = null;
$effect(() => {
const pref = $storedChartRange;
if (liveCharts.length === 0 || timestampsSec.length === 0 || appliedPref === pref) return;
appliedPref = pref;
applyDefaultRange();
});
const now = new Date();
// "Selected day" is redundant while today is the selected day, so hide it then
let rangePresets = $derived.by(() => {
@@ -100,7 +141,7 @@
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]),
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
{ label: 'All', apply: () => resetZoom() }
{ label: 'All', apply: () => showFullRange() }
];
});