2 Commits
Author SHA1 Message Date
Vincent van der Wal d78ac84a12 more fluent design 2026-07-25 11:10:14 +02:00
Vincent van der Wal 562d545c7a past weather 2026-07-25 10:51:21 +02:00
12 changed files with 415 additions and 176 deletions
+9 -5
View File
@@ -250,11 +250,15 @@
let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand)); let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand));
let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right')); let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right'));
// Tighter left gutter on narrow screens so axis labels sit near the edge // Minimal gutters on narrow screens so the plot uses nearly the full width
let padLeft = $derived(width > 0 && width < 520 ? 38 : 60); // (just enough to keep the axis tick labels legible).
let isNarrow = $derived(width > 0 && width < 520);
let padLeft = $derived(isNarrow ? 26 : 60);
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis) // Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
// has a right axis, so a stacked row of charts share the same plot width. // has a right axis, so a stacked row of charts share the same plot width.
let padRight = $derived(hasRightAxis || reserveRightAxis ? 56 : 20); let padRight = $derived(
hasRightAxis || reserveRightAxis ? (isNarrow ? 34 : 56) : isNarrow ? 6 : 20
);
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps // Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
// a stacked row of charts the same height even if some have fewer icon rows. // a stacked row of charts the same height even if some have fewer icon rows.
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0)); let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
@@ -1131,7 +1135,7 @@
<!-- Weather pictograms: a bordered band across the top of the plot --> <!-- Weather pictograms: a bordered band across the top of the plot -->
{#if visiblePictograms.length > 0} {#if visiblePictograms.length > 0}
<div <div
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30" class="pointer-events-none absolute z-10 overflow-hidden rounded-t-lg border border-border/60 bg-muted/30"
style:left="{iconBandLeft}px" style:left="{iconBandLeft}px"
style:top="{pictoRowTop}px" style:top="{pictoRowTop}px"
style:width="{iconBandWidth}px" style:width="{iconBandWidth}px"
@@ -1153,7 +1157,7 @@
<!-- Wind-direction arrows: a matching band --> <!-- Wind-direction arrows: a matching band -->
{#if visibleWindArrows.length > 0} {#if visibleWindArrows.length > 0}
<div <div
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30" class="pointer-events-none absolute z-10 overflow-hidden rounded-t-lg border border-border/60 bg-muted/30"
style:left="{iconBandLeft}px" style:left="{iconBandLeft}px"
style:top="{windRowTop}px" style:top="{windRowTop}px"
style:width="{iconBandWidth}px" style:width="{iconBandWidth}px"
@@ -101,11 +101,11 @@
<style> <style>
.chart-bleed { .chart-bleed {
/* Bleed exactly into the page padding on mobile (main has p-5 = /* Bleed exactly into the page padding on mobile (main has p-3 =
1.25rem) for edge-to-edge charts, and a bit past the content 0.75rem) for edge-to-edge charts, and a bit past the content
column on md+ (main has 2rem padding) for extra readability. */ column on md+ (main has 2rem padding) for extra readability. */
margin-left: -1.25rem; margin-left: -0.75rem;
margin-right: -1.25rem; margin-right: -0.75rem;
overflow-x: auto; overflow-x: auto;
} }
+1 -1
View File
@@ -81,7 +81,7 @@
{location.name} {location.name}
{#if location.admin1 || location.country} {#if location.admin1 || location.country}
<span class="font-normal text-muted-foreground"> <span class="font-normal text-muted-foreground">
· {#if location.admin1}{location.admin1}, · {#if location.admin1}{location.admin1},&nbsp;
{/if}{location.country ?? ''} {/if}{location.country ?? ''}
</span> </span>
{/if} {/if}
+1 -1
View File
@@ -92,7 +92,7 @@ export interface ChartPanel {
export const defaultChartLayout: ChartPanel[] = [ export const defaultChartLayout: ChartPanel[] = [
{ id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] }, { id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] },
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] }, { id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] },
{ id: 'panel-3', variables: ['wind', 'humidity'] } { id: 'panel-3', variables: ['wind', 'wind_direction', 'humidity'] }
]; ];
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout); export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
+1 -1
View File
@@ -83,7 +83,7 @@
<Header onMenuToggle={toggleMobileMenu} /> <Header onMenuToggle={toggleMobileMenu} />
<main <main
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-5 md:px-8 md:py-6'} class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 md:px-8 md:py-6'}
> >
{#if fullBleed} {#if fullBleed}
{@render children()} {@render children()}
@@ -154,6 +154,11 @@
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : [] fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
); );
// Surface a note when the chosen model's ensemble stops short of the request.
let fullHours = $derived.by(() => (fetchedData ? fetchedData.timestamps.length : 0));
let validDays = $derived(Math.max(0, Math.round(validLength / 24)));
let isTrimmed = $derived(fetchedData != null && validLength > 0 && validLength < fullHours - 1);
interface ChartDef { interface ChartDef {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
@@ -210,14 +215,6 @@
bandTo: varData.min, bandTo: varData.min,
format: (v) => `${v.toFixed(1)} ${unit}` format: (v) => `${v.toFixed(1)} ${unit}`
}, },
{
name: 'Min',
type: 'line',
color: BAND_COLOR,
data: varData.min,
width: 1,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{ {
name: 'Mean', name: 'Mean',
type: isColumn ? 'bar' : 'line', type: isColumn ? 'bar' : 'line',
@@ -226,6 +223,14 @@
width: 3, width: 3,
dashed: !isColumn, dashed: !isColumn,
format: (v) => `${v.toFixed(1)} ${unit}` format: (v) => `${v.toFixed(1)} ${unit}`
},
{
name: 'Min',
type: 'line',
color: BAND_COLOR,
data: varData.min,
width: 1,
format: (v) => `${v.toFixed(1)} ${unit}`
} }
]; ];
@@ -285,6 +290,30 @@
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
{#if isTrimmed}
<div
class="mb-4 flex items-start gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
>
<svg
class="mt-0.5 h-4 w-4 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
/>
</svg>
<span>
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead — the spread is
trimmed to its available range.
</span>
</div>
{/if}
{#if loadError} {#if loadError}
<div <div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive" class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
@@ -5,6 +5,8 @@
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings'; import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
@@ -17,6 +19,7 @@
SERIES_COLORS, SERIES_COLORS,
calculateAverage, calculateAverage,
findUnit, findUnit,
groupRange,
isColumnUnit isColumnUnit
} from '$lib/charts'; } from '$lib/charts';
import { import {
@@ -99,6 +102,40 @@
// components persist across refetches; entries are null while unmounted // components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null)); let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
const SECONDS_PER_DAY = 24 * 3600;
function dayStartSec(day: Date): number | null {
if (!fetchedData) return null;
const tz = fetchedData.timezone;
const target = formatZoned(day, tz, 'yyyy-MM-dd');
const idx = fetchedData.timestamps.findIndex(
(t) => formatZoned(new Date(t), tz, 'yyyy-MM-dd') === target
);
return idx === -1 ? null : fetchedData.timestamps[idx] / 1000;
}
function setRangeDays(from: Date, days: number): void {
const start = dayStartSec(from);
if (start == null || liveCharts.length === 0) return;
// charts share the group, so setting the range on one syncs all of them
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
}
function resetZoom(): void {
liveCharts[0]?.resetRange();
}
const rangePresets = [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
{ label: 'All', apply: () => resetZoom() }
];
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
// ─── Data Fetching (only when params.hourly or params.models change) ─────── // ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => { $effect(() => {
@@ -161,6 +198,24 @@
series: ChartSeries[]; series: ChartSeries[];
} }
// Human labels for the compared variables (API names → readable title)
const VAR_LABELS: Record<string, string> = {
temperature_2m: 'Temperature',
apparent_temperature: 'Feels like',
dew_point_2m: 'Dew point',
precipitation: 'Precipitation',
rain: 'Rain',
showers: 'Showers',
snowfall: 'Snowfall',
wind_speed_10m: 'Wind speed',
wind_gusts_10m: 'Wind gusts',
relative_humidity_2m: 'Relative humidity',
cloud_cover: 'Cloud cover',
pressure_msl: 'Pressure (MSL)'
};
const varLabel = (v: string): string =>
VAR_LABELS[v] ?? v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
let chartDefs = $derived.by((): ChartDef[] => { let chartDefs = $derived.by((): ChartDef[] => {
if (!fetchedData) return []; if (!fetchedData) return [];
@@ -209,10 +264,11 @@
const isLast = vi === variableCount - 1; const isLast = vi === variableCount - 1;
defs.push({ defs.push({
title: isFirst ? 'Model Compare' : undefined, // label every chart with its variable so each is identifiable
title: varLabel(variable),
subtitle: isFirst subtitle: isFirst
? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` ? `${params.models?.length ?? 0} models · dashed = average`
: undefined, : `across ${params.models?.length ?? 0} models`,
unit, unit,
showCredit: isLast, showCredit: isLast,
series series
@@ -223,6 +279,73 @@
}); });
</script> </script>
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<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
>Model comparison
</p>
</div>
</div>
<!-- Range / zoom controls, aligned with the title like the other pages -->
<div class="flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground lg:inline">
drag or
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
>
+ scroll to zoom
</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
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Chart time range"
>
{#each rangePresets as preset (preset.label)}
<button
type="button"
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
onclick={preset.apply}
>
{preset.label}
</button>
{/each}
</div>
</div>
</div>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
{#if loadError} {#if loadError}
@@ -35,18 +35,31 @@
// of view — the user reveals it by scrolling left. Re-hide only when the // of view — the user reveals it by scrolling left. Re-hide only when the
// dataset (location) changes, not on every re-render. // dataset (location) changes, not on every re-render.
let scrollEl = $state<HTMLDivElement>(); let scrollEl = $state<HTMLDivElement>();
let pastBtnEl = $state<HTMLButtonElement>(); let cardsWrapEl = $state<HTMLDivElement>();
let hiddenForRef: FetchedDaily | null = null; let hiddenForRef: FetchedDaily | null = null;
// Show the scrollbar only briefly while actively scrolling (a constant thin
// gutter is reserved so revealing the thumb never shifts layout).
let scrolling = $state(false);
let scrollHideTimer: ReturnType<typeof setTimeout> | undefined;
function onScroll() {
scrolling = true;
clearTimeout(scrollHideTimer);
scrollHideTimer = setTimeout(() => (scrolling = false), 700);
}
$effect(() => { $effect(() => {
const d = daily; const d = daily;
if (!d || !canExtendPast || !scrollEl || !pastBtnEl || hiddenForRef === d) return; if (!d || !canExtendPast || !scrollEl || !cardsWrapEl || hiddenForRef === d) return;
hiddenForRef = d; hiddenForRef = d;
const btn = pastBtnEl.getBoundingClientRect(); const el = scrollEl;
const cont = scrollEl.getBoundingClientRect(); const wrap = cardsWrapEl;
// scroll so the button's right edge sits just past the left edge (a small // defer to after layout so the measured positions and scroll width are final
// gap of extra margin keeps the first day card from hugging the edge) requestAnimationFrame(() => {
scrollEl.scrollLeft += btn.right - cont.left + 6; // scroll so the first day card sits exactly at the content edge (aligned
// with the page hero), leaving the "past days" button off to the left
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12;
});
}); });
function getDaylightSeconds(index: number): number { function getDaylightSeconds(index: number): number {
@@ -106,19 +119,19 @@
</defs> </defs>
</svg> </svg>
<div in:fade out:fade class="mb-6 min-h-[260px]"> <div transition:fade={{ duration: 200 }} class="mb-6 min-h-[260px]">
<!-- negative margin + matching padding: the scroll box gains room so a <!-- negative margin + matching padding: the scroll box gains room so a
lifted/scaled/shadowed card is never clipped, while the first card still lifted/scaled/shadowed card is never clipped, while the first card still
lines up with the page content edge --> lines up with the page content edge -->
<div <div
bind:this={scrollEl} bind:this={scrollEl}
class="-mx-3 flex gap-2 overflow-x-auto px-3 pt-4 pb-8" class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-5 pb-11"
style="scrollbar-width: thin" class:scrolling
onscroll={onScroll}
> >
{#if daily} {#if daily}
{#if canExtendPast && onExtendPast} {#if canExtendPast && onExtendPast}
<button <button
bind:this={pastBtnEl}
type="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" 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} onclick={onExtendPast}
@@ -143,157 +156,175 @@
</span> </span>
</button> </button>
{/if} {/if}
{#each daily.dailyDates as time, index (index)} <!-- the cards fill at least the viewport so the row overflows past the
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)} "past days" button (letting it scroll out of view even on wide
{@const tempMax = daily.daily.temperature_2m_max[index]} screens). On md+ we also reserve room so the "load more" button stays
{@const tempMin = daily.daily.temperature_2m_min[index]} visible; on mobile it's simply reached by scrolling (never clipped). -->
{@const wCode = daily.daily.weather_code[index]} <div
{@const sunDuration = daily.daily.sunshine_duration[index]} bind:this={cardsWrapEl}
{@const daylightSec = getDaylightSeconds(index)} class="cards-fill flex gap-2"
{@const sunColor = getSunshineColor(sunDuration, daylightSec)} style="--fill-reserve: {canExtend ? '6.5rem' : '0rem'}"
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)} >
{@const precipSum = daily.daily.precipitation_sum[index]} {#each daily.dailyDates as time, index (index)}
{@const windMax = daily.daily.windspeed_10m_max[index]} {@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const gustMax = daily.daily.windgusts_10m_max[index]} {@const tempMax = daily.daily.temperature_2m_max[index]}
{@const windDir = daily.daily.winddirection_10m_dominant[index]} {@const tempMin = daily.daily.temperature_2m_min[index]}
{@const unit = String(units.temperature_unit)} {@const wCode = daily.daily.weather_code[index]}
{@const maxStyle = getTempStyle(tempMax, unit)} {@const sunDuration = daily.daily.sunshine_duration[index]}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)} {@const daylightSec = getDaylightSeconds(index)}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))} {@const sunColor = getSunshineColor(sunDuration, daylightSec)}
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))} {@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)} {@const precipSum = daily.daily.precipitation_sum[index]}
<button {@const windMax = daily.daily.windspeed_10m_max[index]}
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 {@const gustMax = daily.daily.windgusts_10m_max[index]}
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const unit = String(units.temperature_unit)}
{@const maxStyle = getTempStyle(tempMax, unit)}
{@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="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 {selected
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60' ? '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'}" : '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} aria-pressed={selected}
onclick={() => onSelectDay(time, index)} onclick={() => onSelectDay(time, index)}
>
<!-- Day label -->
<span class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}">
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span
class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
> >
{getRelativeDayLabel(time, daily.timezone)} <!-- Day label -->
</span>
<!-- 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 <span
class="ml-1 rounded-xl px-5 py-1.5 text-xl font-extrabold tabular-nums" class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
> >
{tempMax.toFixed(0)}° {formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span> </span>
<span class="text-lg font-semibold tabular-nums text-muted-foreground"> <span
{tempMin.toFixed(0)}° class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
>
{getRelativeDayLabel(time, daily.timezone)}
</span> </span>
</div>
<!-- Details --> <!-- Weather icon: large day with a night badge in the corner -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5"> <div class="relative my-1 px-3 -ml-2.5">
<!-- Sunshine --> <svg
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}"> class="day-icon fill-foreground"
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}"> width="100px"
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use> height="100px"
style="filter: url(#thin-day-icon)"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg> </svg>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted"> <svg
<div class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
class="h-full rounded-full transition-all" width="42px"
style="width: {sunPct}%; background-color: {sunColor}" height="42px"
></div> >
</div> <use
<span class="text-[10px] font-medium tabular-nums text-muted-foreground"> xlink:href="/images/weather-icons/{getWeatherIconName(
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h wCode,
false
)}.svg#Layer_1"
></use>
</svg>
</div>
<!-- Temperature max/min -->
<div class="flex items-baseline gap-1.5">
<span
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-lg font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</span> </span>
</div> </div>
<!-- Precipitation + wind --> <!-- Details -->
<div <div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80" <!-- Sunshine -->
> <div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
<span <svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
class="inline-flex items-center gap-0.5 {lowPrecip <use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
? '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> </svg>
{Number(precipSum ?? 0).toFixed( <div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
precipSum >= 10 ? 0 : 1 <div
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"} class="h-full rounded-full transition-all"
</span> style="width: {sunPct}%; background-color: {sunColor}"
<span ></div>
class="inline-flex items-center gap-0.5 {lowWind </div>
? 'text-muted-foreground/50' <span class="text-[10px] font-medium tabular-nums text-muted-foreground">
: ''}" {Number((sunDuration ?? 0) / 3600).toFixed(0)}h
</span>
</div>
<!-- Precipitation + wind -->
<div
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
> >
{#if windDir != null && !isNaN(windDir)} <span
<span class="inline-flex items-center gap-0.5 {lowPrecip
class="inline-flex shrink-0 -mr-2" ? 'text-muted-foreground/50'
style="transform: {getWindArrowRotation(windDir)}" : ''}"
>
<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 {lowWind
? 'text-muted-foreground/50'
: ''}"
>
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-flex shrink-0 -mr-2"
style="transform: {getWindArrowRotation(windDir)}"
>
<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 <svg
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'} class="shrink-0 -mr-2 {lowWind
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="40px" width="40px"
height="40px" height="40px"
> >
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg> </svg>
</span> {/if}
{:else} {windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
<svg >-{gustMax?.toFixed(0) ?? '-'}</span
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> </span>
</svg> </div>
{/if}
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
>-{gustMax?.toFixed(0) ?? '-'}</span
>
</span>
</div> </div>
</div> </button>
</button> {/if}
{/if} {/each}
{/each} </div>
{#if canExtend && onExtend} {#if canExtend && onExtend}
<button <button
@@ -326,6 +357,40 @@
</div> </div>
<style> <style>
/* Reserve a constant thin scrollbar gutter (no layout shift), but keep the
thumb invisible until the user is actively scrolling. */
.day-scroll {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.day-scroll.scrolling {
scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent;
}
.day-scroll::-webkit-scrollbar {
height: 8px;
}
.day-scroll::-webkit-scrollbar-thumb {
border-radius: 4px;
background: transparent;
transition: background 0.2s;
}
.day-scroll.scrolling::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-border) 85%, transparent);
}
/* The cards group fills the viewport so the row overflows past the side
buttons. On md+ we also reserve room so the "load more" button stays in
view; on mobile it keeps its full width after the cards (reached by
scrolling) and is never clipped. */
.cards-fill {
min-width: 100%;
}
@media (min-width: 768px) {
.cards-fill {
min-width: calc(100% - var(--fill-reserve, 0rem));
}
}
/* Mobile: keep the exact desktop layout, just scale the whole card down. */ /* Mobile: keep the exact desktop layout, just scale the whole card down. */
@media (max-width: 768px) { @media (max-width: 768px) {
.day-card { .day-card {
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition';
import { storedVariablePrefs } from '$lib/stores/settings'; import { storedVariablePrefs } from '$lib/stores/settings';
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date'; import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
@@ -236,10 +238,11 @@
{#if cellData.length > 0} {#if cellData.length > 0}
{@const hourly = data.hourly} {@const hourly = data.hourly}
{@const iconPx = is3h ? 38 : 33} {@const iconPx = is3h ? 38 : 33}
<!-- Full-bleed to the viewport edges on mobile (main has p-5 = 1.25rem); <!-- Full-bleed to the viewport edges on mobile (main has p-3 = 0.75rem);
a contained rounded card on md+ --> a contained rounded card on md+ -->
<section <section
class="-mx-5 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border" transition: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"
> >
<!-- Card toolbar --> <!-- Card toolbar -->
<div <div
@@ -153,7 +153,7 @@
); );
</script> </script>
<section class="mt-8" in:fade={{ duration: 200 }}> <section class="mt-8" transition:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold"> <h3 class="text-lg font-bold">
Meteograms Meteograms
@@ -237,14 +237,18 @@
>. >.
</div> </div>
{:else} {:else}
<div class="flex flex-col gap-6"> <!-- one full-bleed card on mobile / contained card on md+, graphs stacked
tightly so they read as one fluent meteogram -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border">
{#each renderPanels as panel, i (panel.id)} {#each renderPanels as panel, i (panel.id)}
<!-- full-bleed to the screen edges on mobile; a contained card on md+ -->
<div <div
class="-mx-5 border-y border-border/70 bg-card px-0 py-3 shadow-sm md:mx-0 md:rounded-2xl md:border md:px-4 md:py-4" class="px-0 pt-2 pb-1 md:px-4 {i > 0 ? 'border-t border-border/50' : 'md:pt-4'} {i ===
renderPanels.length - 1
? 'pb-3 md:pb-4'
: ''}"
> >
<div class="mb-1 flex items-center justify-between px-3 md:px-1"> <div class="mb-0.5 flex items-center justify-between px-3 md:px-0">
<h4 class="truncate text-sm font-bold text-muted-foreground"> <h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
<span class="hidden md:inline">{panel.title}</span> <span class="hidden md:inline">{panel.title}</span>
<span class="md:hidden">{panel.titleShort}</span> <span class="md:hidden">{panel.titleShort}</span>
</h4> </h4>
@@ -42,7 +42,7 @@
> >
<Select.Trigger <Select.Trigger
aria-label="{label} selection" aria-label="{label} selection"
class="group h-auto min-w-0 flex-1 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72 sm:flex-none" class="group h-auto min-h-14 min-w-0 flex-1 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72 sm:flex-none"
> >
<div <div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary" class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
@@ -201,8 +201,19 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
color: '#26a69a', color: '#26a69a',
width: 2, width: 2,
fill: true, fill: true,
fillOpacity: 0.15, fillOpacity: 0.15
windArrows: true },
{
key: 'wind_direction',
label: 'Wind direction',
short: 'Dir',
field: 'winddirection_10m',
api: 'wind_direction_10m',
type: 'line',
kind: 'wind',
color: '#14b8a6',
windArrows: true,
marker: true
}, },
{ {
key: 'wind_gusts', key: 'wind_gusts',
@@ -325,7 +336,7 @@ export function neededHourlyApiVars(
if (!def) continue; if (!def) continue;
s.add(apiNameOf(def)); s.add(apiNameOf(def));
if (def.pictograms) s.add('weather_code'); if (def.pictograms) s.add('weather_code');
if (def.key === 'wind') s.add('wind_direction_10m'); if (def.windArrows || def.key === 'wind') s.add('wind_direction_10m');
} }
return [...s]; return [...s];