better cards, local weather codes
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { precipIsSignificant, windIsSignificant } from './significance';
|
||||
import { type FetchedDaily, type WeatherUnits } from './types';
|
||||
|
||||
interface Props {
|
||||
@@ -30,63 +31,38 @@
|
||||
}: Props = $props();
|
||||
|
||||
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
|
||||
// 0 = full cards (at the top of the page), 1 = compact square strip (stuck).
|
||||
// The progress sentinel sits ABOVE the sticky strip, so the strip shrinking
|
||||
// (which reflows the table BELOW it) never feeds back into the measurement.
|
||||
let progress = $state(0);
|
||||
// The strip collapses from full cards to a compact strip as it sticks. All
|
||||
// sizing derives from a single registered custom property `--strip-p` (0 =
|
||||
// full, 1 = compact):
|
||||
//
|
||||
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
|
||||
// scrub `--strip-p` natively from a view-timeline on the sentinel below —
|
||||
// no JS runs during scroll at all, so there is no rAF frame-lag or jitter.
|
||||
// * Everything else (Firefox, older Safari) snaps between the two states
|
||||
// with a short CSS transition instead: an IntersectionObserver on the same
|
||||
// sentinel toggles `.compact`. No per-frame scroll handler anywhere.
|
||||
let sentinelEl = $state<HTMLDivElement>();
|
||||
let stripScrollEl = $state<HTMLDivElement>();
|
||||
let daysWrapEl = $state<HTMLDivElement>();
|
||||
// Resolved in onMount (client only) so this never touches `window` during the
|
||||
// prerender of city pages.
|
||||
let scrollParent: HTMLElement | Window | null = $state(null);
|
||||
|
||||
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
|
||||
|
||||
function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
|
||||
let node = el?.parentElement ?? null;
|
||||
while (node) {
|
||||
const oy = getComputedStyle(node).overflowY;
|
||||
if (oy === 'auto' || oy === 'scroll') return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
// Distance (px) over which the collapse plays out once the strip sticks.
|
||||
const RANGE = 120;
|
||||
let ticking = false;
|
||||
|
||||
function measure() {
|
||||
ticking = false;
|
||||
if (!sentinelEl || !scrollParent) return;
|
||||
const topRef = scrollParent instanceof Window ? 0 : scrollParent.getBoundingClientRect().top;
|
||||
const top = sentinelEl.getBoundingClientRect().top - topRef;
|
||||
progress = clamp(-top / RANGE);
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(measure);
|
||||
}
|
||||
let needsSnapFallback = $state(false);
|
||||
let compact = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
scrollParent = findScrollParent(sentinelEl ?? null);
|
||||
const target: EventTarget = scrollParent;
|
||||
target.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll, { passive: true });
|
||||
measure();
|
||||
return () => {
|
||||
target.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
};
|
||||
});
|
||||
|
||||
// Re-measure once data (and therefore the strip height) changes.
|
||||
$effect(() => {
|
||||
void daily;
|
||||
tick().then(measure);
|
||||
const scrubSupported =
|
||||
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
|
||||
if (scrubSupported || !sentinelEl) return;
|
||||
needsSnapFallback = true;
|
||||
// Collapse once more than half of the sentinel band has scrolled past the
|
||||
// top; expands again on the same boundary (the transition smooths both).
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const e = entries[entries.length - 1];
|
||||
compact = e.intersectionRatio < 0.5;
|
||||
},
|
||||
{ threshold: [0.25, 0.5, 0.75] }
|
||||
);
|
||||
io.observe(sentinelEl);
|
||||
return () => io.disconnect();
|
||||
});
|
||||
|
||||
// Start scrolled so the "Past" button sits just off the left edge (revealed by
|
||||
@@ -105,17 +81,21 @@
|
||||
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
|
||||
});
|
||||
});
|
||||
|
||||
// All progress-driven sizing lives in CSS (via the single `--p` custom property
|
||||
// set on the strip), so a scroll frame writes ONE value instead of re-patching
|
||||
// height/padding/opacity inline styles on every cell.
|
||||
</script>
|
||||
|
||||
<!-- progress sentinel: 0-height marker just above the sticky strip -->
|
||||
<div bind:this={sentinelEl} aria-hidden="true"></div>
|
||||
<!-- progress sentinel: an invisible 120px band (taking no layout space) just
|
||||
above the sticky strip. Its exit across the scrollport top drives the
|
||||
collapse — via view-timeline where supported, IntersectionObserver
|
||||
otherwise. It sits above the strip so the strip shrinking (which reflows
|
||||
the table below) never feeds back into the measurement. -->
|
||||
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
|
||||
|
||||
<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="daystrip sticky -top-3 z-30 -mx-3 md:hidden"
|
||||
class:js-snap={needsSnapFallback}
|
||||
class:compact
|
||||
>
|
||||
<div class="strip-row flex overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
|
||||
{#if daily}
|
||||
{#if canExtendPast && onExtendPast}
|
||||
<button
|
||||
@@ -137,15 +117,19 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-1.5" bind:this={daysWrapEl}>
|
||||
<div class="strip-days flex" bind:this={daysWrapEl}>
|
||||
{#each daily.dailyDates as time, index (index)}
|
||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||
{@const wCode = daily.daily.weather_code[index]}
|
||||
{@const dayCode = daily.dayCodes?.[index] ?? wCode}
|
||||
{@const nightCode = daily.nightCodes?.[index] ?? wCode}
|
||||
{@const precipSum = daily.daily.precipitation_sum[index]}
|
||||
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
|
||||
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
|
||||
{@const lowWind = !windIsSignificant(windMax, null, String(units.wind_speed_unit))}
|
||||
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -156,19 +140,29 @@
|
||||
onclick={() => onSelectDay(time, index)}
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-semibold tracking-wide {selected ? 'text-primary' : ''}"
|
||||
class="text-[11px] font-semibold tracking-wide whitespace-nowrap {selected
|
||||
? 'text-primary'
|
||||
: ''}"
|
||||
>
|
||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}<span
|
||||
class="date-inline align-baseline font-medium tabular-nums text-muted-foreground"
|
||||
> {formatZoned(time, daily.timezone, 'd')}</span
|
||||
>
|
||||
</span>
|
||||
|
||||
<span class="rel-label overflow-hidden text-[9px] leading-none text-muted-foreground">
|
||||
<span
|
||||
class="rel-label overflow-hidden text-[9px] leading-[1.25] whitespace-nowrap text-muted-foreground"
|
||||
>
|
||||
{getRelativeDayLabel(time, daily.timezone)}
|
||||
</span>
|
||||
|
||||
<div class="relative">
|
||||
<svg class="day-icon fill-foreground">
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
dayCode,
|
||||
true
|
||||
)}.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
<!-- night companion icon, present in the full view, fades as it collapses -->
|
||||
@@ -177,7 +171,7 @@
|
||||
>
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
wCode,
|
||||
nightCode,
|
||||
false
|
||||
)}.svg#Layer_1"
|
||||
></use>
|
||||
@@ -186,12 +180,12 @@
|
||||
|
||||
<div class="flex items-baseline gap-1 leading-none">
|
||||
<span
|
||||
class="rounded-md px-1.5 py-0.5 text-[12px] font-extrabold tabular-nums"
|
||||
class="temp-max rounded-md py-0.5 font-extrabold tabular-nums"
|
||||
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
||||
>
|
||||
{tempMax.toFixed(0)}°
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold tabular-nums text-muted-foreground">
|
||||
<span class="temp-min font-semibold tabular-nums text-muted-foreground">
|
||||
{tempMin.toFixed(0)}°
|
||||
</span>
|
||||
</div>
|
||||
@@ -199,13 +193,13 @@
|
||||
<div
|
||||
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
|
||||
<svg class="fill-sky-500" width="13" height="13">
|
||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||
</svg>
|
||||
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-flex items-center gap-1 {lowWind ? 'opacity-40' : ''}">
|
||||
<svg class="fill-muted-foreground" width="13" height="13">
|
||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||
</svg>
|
||||
@@ -241,29 +235,51 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Everything below is derived from a single custom property `--p` (0 = full
|
||||
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. */
|
||||
/* `--strip-p` is registered so it interpolates: scroll-driven keyframes scrub
|
||||
it continuously, and the snap fallback's `transition` eases it 0 ↔ 1.
|
||||
Everything below derives from it via calc(), so a scroll frame is one
|
||||
property update resolved natively by the browser — no JS, no inline-style
|
||||
patches across cells. */
|
||||
@property --strip-p {
|
||||
syntax: '<number>';
|
||||
inherits: true;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
/* Invisible 120px collapse band: the distance over which the collapse plays
|
||||
out. The negative margin removes it from layout so nothing shifts. */
|
||||
.sentinel {
|
||||
height: 120px;
|
||||
margin-bottom: -120px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
--cell-w-full: 76px;
|
||||
--cell-w-min: 56px;
|
||||
--cell-h-full: 146px;
|
||||
--cell-h-min: 66px;
|
||||
--icon-full: 44px;
|
||||
--icon-min: 21px;
|
||||
--pt-full: 7px;
|
||||
--pt-min: 2px;
|
||||
--gap-full: 8px;
|
||||
--gap-min: 4px;
|
||||
|
||||
--strip-p: 0;
|
||||
|
||||
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
|
||||
--k: calc(1 - var(--p));
|
||||
--k: calc(1 - var(--strip-p));
|
||||
--cell-w: calc(var(--cell-w-min) + (var(--cell-w-full) - var(--cell-w-min)) * var(--k));
|
||||
--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);
|
||||
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
|
||||
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
|
||||
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
|
||||
/* bar background/divider fade in once it starts collapsing */
|
||||
--chrome: clamp(0, calc(var(--p) / 0.35), 1);
|
||||
--chrome: clamp(0, calc(var(--strip-p) / 0.35), 1);
|
||||
|
||||
/* Opaque-background fade only — no backdrop-filter blur or animated
|
||||
box-shadow (both are very expensive to repaint every scroll frame on
|
||||
@@ -278,11 +294,48 @@
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Scrub path: the sentinel's exit across the scrollport top maps directly to
|
||||
--strip-p 0→1 (the page wrapper hoists the timeline name via
|
||||
`timeline-scope` so this sibling can reference it). */
|
||||
@supports (animation-timeline: view()) and (timeline-scope: none) {
|
||||
.sentinel {
|
||||
view-timeline: --daystrip-sentinel block;
|
||||
}
|
||||
.daystrip {
|
||||
animation: strip-collapse linear both;
|
||||
animation-timeline: --daystrip-sentinel;
|
||||
animation-range: exit 0% exit 100%;
|
||||
}
|
||||
}
|
||||
@keyframes strip-collapse {
|
||||
to {
|
||||
--strip-p: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Snap fallback: ease between the two end states instead of scrubbing.
|
||||
(Browsers too old to register --strip-p simply switch instantly.) */
|
||||
.daystrip.js-snap {
|
||||
transition: --strip-p 0.28s ease;
|
||||
}
|
||||
.daystrip.js-snap.compact {
|
||||
--strip-p: 1;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.daystrip.js-snap {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.strip-row,
|
||||
.strip-days {
|
||||
gap: var(--gap);
|
||||
}
|
||||
.strip-cell,
|
||||
.strip-side {
|
||||
width: var(--cell-w);
|
||||
height: var(--cell-h);
|
||||
/* size tracks scroll exactly (no transition); only the tap highlight eases */
|
||||
/* size tracks the collapse exactly; only the tap highlight eases */
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background-color 0.15s;
|
||||
@@ -299,6 +352,19 @@
|
||||
height: calc(var(--icon) * 0.44);
|
||||
opacity: var(--rel);
|
||||
}
|
||||
/* Day-of-month slides in next to the weekday as the cards collapse
|
||||
("MON" → "MON 12"), replacing the relative label that fades out. */
|
||||
.date-inline {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
/* overflow≠visible makes an inline-block's baseline its bottom edge, which
|
||||
would render the digits superscript; text-bottom restores baseline
|
||||
alignment (same font size as the weekday, so descents match). */
|
||||
vertical-align: text-bottom;
|
||||
max-width: calc(20px * (1 - var(--rel)));
|
||||
opacity: calc(1 - var(--rel));
|
||||
}
|
||||
.rel-label {
|
||||
opacity: var(--rel);
|
||||
max-height: calc(14px * var(--rel));
|
||||
@@ -307,6 +373,13 @@
|
||||
opacity: var(--detail);
|
||||
max-height: calc(42px * var(--detail));
|
||||
}
|
||||
.temp-max {
|
||||
font-size: calc(11px + 1px * var(--k));
|
||||
padding-inline: calc(3px + 3px * var(--k));
|
||||
}
|
||||
.temp-min {
|
||||
font-size: calc(10px + 1px * var(--k));
|
||||
}
|
||||
|
||||
.daystrip :global(.overflow-x-auto) {
|
||||
scrollbar-width: none;
|
||||
|
||||
Reference in New Issue
Block a user