revamp weather UI: cards, meteograms, units in header, location favorites and range controls

This commit is contained in:
2026-07-25 10:26:14 +02:00
committed by Vincent van der Wal
parent bac4759662
commit b4e509f9cb
18 changed files with 1023 additions and 275 deletions
@@ -4,7 +4,7 @@
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes';
import { getWeatherIconName } from '../../utils/weather-codes';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
interface Props {
@@ -12,9 +12,42 @@
selectedDay: Date;
units: WeatherUnits;
onSelectDay: (date: Date, index: number) => void;
/** Offer a button after the last day to load the model's longer range */
canExtend?: boolean;
onExtend?: () => void;
/** Offer a button before the first day to load recent past days */
canExtendPast?: boolean;
onExtendPast?: () => void;
}
let { daily, selectedDay, units, onSelectDay }: Props = $props();
let {
daily,
selectedDay,
units,
onSelectDay,
canExtend = false,
onExtend,
canExtendPast = false,
onExtendPast
}: Props = $props();
// The "past days" button sits before the first card but starts scrolled out
// of view — the user reveals it by scrolling left. Re-hide only when the
// dataset (location) changes, not on every re-render.
let scrollEl = $state<HTMLDivElement>();
let pastBtnEl = $state<HTMLButtonElement>();
let hiddenForRef: FetchedDaily | null = null;
$effect(() => {
const d = daily;
if (!d || !canExtendPast || !scrollEl || !pastBtnEl || hiddenForRef === d) return;
hiddenForRef = d;
const btn = pastBtnEl.getBoundingClientRect();
const cont = scrollEl.getBoundingClientRect();
// scroll so the button's right edge sits just past the left edge (a small
// gap of extra margin keeps the first day card from hugging the edge)
scrollEl.scrollLeft += btn.right - cont.left + 6;
});
function getDaylightSeconds(index: number): number {
if (!daily) return 0;
@@ -34,14 +67,82 @@
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
if (ratio >= 0.7) return '#f59e0b';
if (ratio >= 0.45) return '#fbbf24';
if (ratio >= 0.2) return '#fcd34d';
if (ratio >= 0.1) return '#fcd34d';
return '#d1d5db';
}
// ─── "Is this metric worth highlighting?" thresholds ────────────────────────
// Below these, the sun / precip / wind bits are greyed out so a card at a
// glance only emphasises what's actually notable that day.
function sunIsSignificant(sunshineSeconds: number | null, daylightSeconds: number): boolean {
if (daylightSeconds <= 0) return false;
return (sunshineSeconds ?? 0) / daylightSeconds >= 0.1;
}
function precipIsSignificant(sum: number | null, unit: string): boolean {
const min = unit === 'mm' ? 0.1 : 0.005; // anything above a trace
return (sum ?? 0) >= min;
}
function windIsSignificant(speed: number | null, gust: number | null, unit: string): boolean {
// separate bars: sustained wind ~ a light breeze (~12 km/h), gusts a bit
// higher (~22 km/h). If EITHER is met, the whole wind readout is coloured.
const windMin = unit === 'ms' ? 3 : unit === 'mph' ? 7 : unit === 'kn' ? 6 : 12;
const gustMin = unit === 'ms' ? 6 : unit === 'mph' ? 14 : unit === 'kn' ? 12 : 22;
const s = speed != null && !isNaN(speed) ? speed : -Infinity;
const g = gust != null && !isNaN(gust) ? gust : -Infinity;
return s >= windMin || g >= gustMin;
}
</script>
<!-- Shared filter: erodes the filled weather glyphs slightly so their
lines read a touch thinner at large sizes (radius = how much to shave) -->
<svg aria-hidden="true" width="0" height="0" class="absolute">
<defs>
<filter id="thin-day-icon" x="-10%" y="-10%" width="120%" height="120%">
<feMorphology operator="erode" radius="0.45" />
</filter>
</defs>
</svg>
<div in:fade out:fade class="mb-6 min-h-[260px]">
<div class="flex gap-2 overflow-x-auto p-1 pb-2" style="scrollbar-width: thin">
<!-- negative margin + matching padding: the scroll box gains room so a
lifted/scaled/shadowed card is never clipped, while the first card still
lines up with the page content edge -->
<div
bind:this={scrollEl}
class="-mx-3 flex gap-2 overflow-x-auto px-3 pt-4 pb-8"
style="scrollbar-width: thin"
>
{#if daily}
{#if canExtendPast && onExtendPast}
<button
bind:this={pastBtnEl}
type="button"
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
onclick={onExtendPast}
aria-label="Load recent past days"
>
<svg
class="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M14 13l-3 3 3 3" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold">
Past<br />3 days
</span>
</button>
{/if}
{#each daily.dailyDates as time, index (index)}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const tempMax = daily.daily.temperature_2m_max[index]}
@@ -57,41 +158,62 @@
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const unit = String(units.temperature_unit)}
{@const maxStyle = getTempStyle(tempMax, unit)}
{#if tempMax != null && !isNaN(tempMax)}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button
class="group relative flex min-w-[112px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
{selected
? 'border-primary/50 bg-primary/5 shadow-md ring-2 ring-primary/40'
: 'border-border/60 bg-card shadow-xs hover:-translate-y-0.5 hover:border-border hover:shadow-md'}"
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60'
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
aria-pressed={selected}
onclick={() => onSelectDay(time, index)}
>
<!-- Day label -->
<span class="text-[13px] font-semibold tracking-wider">
<span class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}">
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span class="-mt-1 text-[11px] text-muted-foreground">
<span
class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
>
{getRelativeDayLabel(time, daily.timezone)}
</span>
<!-- Weather icon -->
<svg class="day-icon my-1 fill-foreground" width="46px" height="46px">
<use
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
wCode as keyof typeof weatherCodes
] ?? 'clear'}.svg#Layer_1"
></use>
</svg>
<!-- Weather icon: large day with a night badge in the corner -->
<div class="relative my-1 px-3 -ml-2.5">
<svg
class="day-icon fill-foreground"
width="100px"
height="100px"
style="filter: url(#thin-day-icon)"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg>
<svg
class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
width="42px"
height="42px"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, false)}.svg#Layer_1"
></use>
</svg>
</div>
<!-- Temperature max/min -->
<div class="flex items-baseline gap-1.5">
<span
class="rounded-lg px-2 py-0.5 text-[15px] font-bold tabular-nums"
class="ml-1 rounded-xl px-5 py-1.5 text-xl font-extrabold tabular-nums"
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
>
{tempMax.toFixed(0)}°
</span>
<span class="text-sm font-medium tabular-nums text-muted-foreground">
<span class="text-lg font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</span>
</div>
@@ -99,8 +221,8 @@
<!-- Details -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
<!-- Sunshine -->
<div class="flex w-full items-center gap-1.5">
<svg class="shrink-0" width="13px" height="13px" style="fill: {sunColor}">
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
</svg>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
@@ -118,30 +240,52 @@
<div
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
>
<span class="inline-flex items-center gap-0.5">
<svg class="shrink-0 fill-foreground/70" width="13px" height="13px">
<span
class="inline-flex items-center gap-0.5 {lowPrecip
? 'text-muted-foreground/50'
: ''}"
>
<svg
class="shrink-0 {lowPrecip ? 'fill-muted-foreground/40' : 'fill-foreground/70'}"
width="23px"
height="23px"
>
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(
precipSum >= 10 ? 0 : 1
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
</span>
<span class="inline-flex items-center gap-0.5">
<span
class="inline-flex items-center gap-0.5 {lowWind
? 'text-muted-foreground/50'
: ''}"
>
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-flex shrink-0"
class="inline-flex shrink-0 -mr-2"
style="transform: {getWindArrowRotation(windDir)}"
>
<svg class="fill-foreground/70" width="16px" height="16px">
<svg
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'}
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg>
</span>
{:else}
<svg class="shrink-0 fill-foreground/70" width="16px" height="16px">
<svg
class="shrink-0 -mr-2 {lowWind
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{/if}
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
>-{gustMax?.toFixed(0) ?? '-'}</span
>
</span>
@@ -150,19 +294,42 @@
</button>
{/if}
{/each}
{#if canExtend && onExtend}
<button
type="button"
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
<svg
class="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold">
Load<br />15 days
</span>
</button>
{/if}
{/if}
</div>
</div>
<style>
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
@media (max-width: 768px) {
button {
min-width: 96px !important;
}
button :global(.day-icon) {
width: 38px;
height: 38px;
.day-card {
zoom: 0.72;
}
}
</style>