perf enhancements

This commit is contained in:
Vincent van der Wal
2026-07-25 16:30:58 +02:00
parent cc1589b017
commit d37c95a46d
4 changed files with 89 additions and 62 deletions
@@ -131,15 +131,6 @@
> >
+ scroll to zoom + scroll to zoom
</span> </span>
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
Reset zoom
</button>
{/if}
<button <button
type="button" type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50" class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
@@ -149,6 +140,17 @@
> >
{#if downloadingPng}Rendering…{:else}PNG{/if} {#if downloadingPng}Rendering…{:else}PNG{/if}
</button> </button>
<!-- Kept last so toggling it wraps onto a new line instead of shifting
the other controls (no layout shift on mobile). -->
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
Reset zoom
</button>
{/if}
</div> </div>
</div> </div>
@@ -154,7 +154,7 @@
<div class="week-page"> <div class="week-page">
<div class="weather-content" style="min-height: 50vh"> <div class="weather-content" style="min-height: 50vh">
<!-- Page hero: prominent location + weather model selection --> <!-- Page hero: prominent location + weather model selection -->
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5"> <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"> <div class="flex min-w-0 items-center gap-3">
<img <img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border" class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
@@ -42,7 +42,6 @@
let scrollParent: HTMLElement | Window | null = $state(null); let scrollParent: HTMLElement | Window | null = $state(null);
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v)); const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const lerp = (a: number, b: number) => a + (b - a) * progress;
function findScrollParent(el: HTMLElement | null): HTMLElement | Window { function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
let node = el?.parentElement ?? null; let node = el?.parentElement ?? null;
@@ -107,34 +106,21 @@
}); });
}); });
// ─── Derived sizing ───────────────────────────────────────────────────────── // All progress-driven sizing lives in CSS (via the single `--p` custom property
const CELL_W = 64; // set on the strip), so a scroll frame writes ONE value instead of re-patching
// Full height fits the whole stack (weekday, label, icon, temps, precip+wind) // height/padding/opacity inline styles on every cell.
// without clipping; the horizontal scroll container also clips vertically, so
// the content must fit inside the cell.
let cellH = $derived(lerp(134, 64));
let iconSize = $derived(lerp(40, 20));
// Top padding eases from pt-1.5 (6px) when full to pt-0.5 (2px) when compact.
let padTop = $derived(lerp(6, 1));
// The secondary rows collapse (height + opacity together) faster than the
// overall shrink, so the cell reaches a clean square before it stops shrinking.
let detailFactor = $derived(clamp(1 - progress * 1.6));
let relFactor = $derived(clamp(1 - progress * 2));
// Bar background/divider/shadow fade in promptly once it starts collapsing.
let chromeOpacity = $derived(clamp(progress / 0.35));
</script> </script>
<!-- progress sentinel: 0-height marker just above the sticky strip --> <!-- progress sentinel: 0-height marker just above the sticky strip -->
<div bind:this={sentinelEl} aria-hidden="true"></div> <div bind:this={sentinelEl} aria-hidden="true"></div>
<div class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" style="--chrome:{chromeOpacity}"> <div class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" style="--p:{progress}">
<div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}> <div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
{#if daily} {#if daily}
{#if canExtendPast && onExtendPast} {#if canExtendPast && onExtendPast}
<button <button
type="button" type="button"
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground" class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
style="width:{CELL_W}px;height:{cellH}px"
onclick={onExtendPast} onclick={onExtendPast}
aria-label="Load recent past days" aria-label="Load recent past days"
> >
@@ -166,7 +152,6 @@
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 {selected class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 {selected
? 'border-primary bg-primary/10 ring-1 ring-primary/50' ? 'border-primary bg-primary/10 ring-1 ring-primary/50'
: 'border-border/60 bg-card'}" : 'border-border/60 bg-card'}"
style="width:{CELL_W}px;height:{cellH}px;padding-top:{padTop}px"
aria-pressed={selected} aria-pressed={selected}
onclick={() => onSelectDay(time, index)} onclick={() => onSelectDay(time, index)}
> >
@@ -176,25 +161,19 @@
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()} {formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span> </span>
<span <span class="rel-label overflow-hidden text-[9px] leading-none text-muted-foreground">
class="overflow-hidden text-[9px] leading-none text-muted-foreground"
style="opacity:{relFactor};max-height:{relFactor * 14}px"
>
{getRelativeDayLabel(time, daily.timezone)} {getRelativeDayLabel(time, daily.timezone)}
</span> </span>
<div class="relative"> <div class="relative">
<svg class="fill-foreground" width="{iconSize}px" height="{iconSize}px"> <svg class="day-icon fill-foreground">
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1" xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use> ></use>
</svg> </svg>
<!-- night companion icon, present in the full view, fades as it collapses --> <!-- night companion icon, present in the full view, fades as it collapses -->
<svg <svg
class="absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60" class="night-badge absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60"
width="{iconSize * 0.44}px"
height="{iconSize * 0.44}px"
style="opacity:{relFactor}"
> >
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
@@ -218,8 +197,7 @@
</div> </div>
<div <div
class="flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground" class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
style="opacity:{detailFactor};max-height:{detailFactor * 42}px"
> >
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<svg class="fill-sky-500" width="13" height="13"> <svg class="fill-sky-500" width="13" height="13">
@@ -242,7 +220,6 @@
<button <button
type="button" type="button"
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground" class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
style="width:{CELL_W}px;height:{cellH}px"
onclick={onExtend} onclick={onExtend}
aria-label="Load the longer-range forecast" aria-label="Load the longer-range forecast"
> >
@@ -264,9 +241,30 @@
</div> </div>
<style> <style>
/* The bar background/divider/shadow fade in only once the strip has collapsed /* Everything below is derived from a single custom property `--p` (0 = full
into its compact form, so at the top of the page it reads as plain cards. */ cards, 1 = compact strip) set on `.daystrip` once per scroll frame. The
browser resolves the calc()s natively, so a scroll frame is one property
write instead of dozens of inline-style patches across every cell. */
.daystrip { .daystrip {
/* tunables */
--cell-w: 64px;
--cell-h-full: 134px;
--cell-h-min: 64px;
--icon-full: 40px;
--icon-min: 20px;
--pt-full: 6px;
--pt-min: 1px;
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
--k: calc(1 - var(--p));
--cell-h: calc(var(--cell-h-min) + (var(--cell-h-full) - var(--cell-h-min)) * var(--k));
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
--rel: clamp(0, calc(1 - var(--p) * 2), 1);
--detail: clamp(0, calc(1 - var(--p) * 1.6), 1);
/* bar background/divider/shadow fade in once it starts collapsing */
--chrome: clamp(0, calc(var(--p) / 0.35), 1);
background: color-mix( background: color-mix(
in oklab, in oklab,
var(--color-background) calc(var(--chrome) * 100%), var(--color-background) calc(var(--chrome) * 100%),
@@ -277,12 +275,37 @@
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent); color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
box-shadow: 0 6px 12px -8px rgba(0, 0, 0, calc(var(--chrome) * 0.35)); box-shadow: 0 6px 12px -8px rgba(0, 0, 0, calc(var(--chrome) * 0.35));
} }
.strip-cell, .strip-cell,
.strip-side { .strip-side {
width: var(--cell-w);
height: var(--cell-h);
/* size tracks scroll exactly (no transition); only the tap highlight eases */
transition: transition:
border-color 0.15s, border-color 0.15s,
background-color 0.15s; background-color 0.15s;
} }
.strip-cell {
padding-top: var(--pt);
}
.day-icon {
width: var(--icon);
height: var(--icon);
}
.night-badge {
width: calc(var(--icon) * 0.44);
height: calc(var(--icon) * 0.44);
opacity: var(--rel);
}
.rel-label {
opacity: var(--rel);
max-height: calc(14px * var(--rel));
}
.detail-row {
opacity: var(--detail);
max-height: calc(42px * var(--detail));
}
.daystrip :global(.overflow-x-auto) { .daystrip :global(.overflow-x-auto) {
scrollbar-width: none; scrollbar-width: none;
} }
@@ -192,25 +192,6 @@
> >
+ scroll to zoom + scroll to zoom
</span> </span>
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
</svg>
Reset zoom
</button>
{/if}
<div <div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold" class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group" role="group"
@@ -276,6 +257,27 @@
{/if} {/if}
PNG PNG
</button> </button>
<!-- Kept last so toggling it wraps onto a new line instead of shifting
the other controls (no layout shift on mobile). -->
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
</svg>
Reset zoom
</button>
{/if}
</div> </div>
</div> </div>