strip more seamless and margin improvements

This commit is contained in:
Vincent van der Wal
2026-08-01 11:38:02 +02:00
parent 99c1a89537
commit a0379a1fd6
13 changed files with 706 additions and 188 deletions
+44 -10
View File
@@ -152,6 +152,10 @@
group?: string;
/** Fixed left-axis minimum (otherwise derived from data, including 0) */
yMin?: number;
/** Minimum breathing room (axis units) above the left-axis data range. */
yPadTop?: number;
/** Minimum breathing room (axis units) below the left-axis data range. */
yPadBottom?: number;
/** Fixed left-axis maximum */
yMax?: number;
/** Force the derived left axis to include zero (default true) */
@@ -196,6 +200,8 @@
group,
yMin,
yMax,
yPadTop,
yPadBottom,
zeroBaseLeft = true,
yMinRight,
yMaxRight,
@@ -302,9 +308,11 @@
// and the right-axis labels are drawn overlaid on top instead (see below).
let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
// 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
// but only on wide screens: on mobile that uniform band wastes precious
// vertical space, so each chart reserves just its own rows there.
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows));
// tighter top/bottom gutters on mobile so charts don't waste vertical space
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
@@ -353,16 +361,38 @@
return [lo, hi];
}
function buildScale(lo: number, hi: number, loFixed: boolean, hiFixed: boolean): Scale {
function buildScale(
lo: number,
hi: number,
loFixed: boolean,
hiFixed: boolean,
halfStepBounds = false
): Scale {
const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true);
const min = loFixed ? lo : Math.floor(lo / step) * step;
const max = hiFixed ? hi : Math.ceil(hi / step) * step;
// Padded axes (e.g. temperature) may end on HALF steps — 5° when ticks
// are every 10° — so the requested margin isn't inflated to a whole step.
// Tick drawing starts at the first full-step multiple, so a half-step
// bound gets no label or gridline of its own.
const snap = halfStepBounds ? step / 2 : step;
const min = loFixed ? lo : Math.floor(lo / snap) * snap;
const max = hiFixed ? hi : Math.ceil(hi / snap) * snap;
return { min, max: max > min ? max : min + step, step };
}
/** First tick at or above the scale minimum (bounds may sit on half steps). */
function firstTick(scale: Scale): number {
return Math.ceil((scale.min - 1e-9) / scale.step) * scale.step;
}
let leftScale = $derived.by((): Scale => {
const [dLo, dHi] = dataExtent('left', zeroBaseLeft);
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
let [dLo, dHi] = dataExtent('left', zeroBaseLeft);
// requested breathing room around the data (e.g. temperature): at least
// the given units, growing with wide ranges so it stays proportionate
const span = dHi - dLo;
const padded = yPadTop != null || yPadBottom != null;
if (yMax === undefined && yPadTop) dHi += Math.max(yPadTop, span * 0.08);
if (yMin === undefined && yPadBottom) dLo -= Math.max(yPadBottom, span * 0.12);
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined, padded);
});
let rightScale = $derived.by((): Scale => {
@@ -839,7 +869,11 @@
ctx.font = font;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let v = leftScale.min; v <= leftScale.max + leftScale.step / 2; v += leftScale.step) {
for (
let v = firstTick(leftScale);
v <= leftScale.max + leftScale.step / 2;
v += leftScale.step
) {
const y = yPix(v, 'left');
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
@@ -858,7 +892,7 @@
ctx.textAlign = 'left';
ctx.fillStyle = textColor;
for (
let v = rightScale.min;
let v = firstTick(rightScale);
v <= rightScale.max + rightScale.step / 2;
v += rightScale.step
) {
@@ -1153,7 +1187,7 @@
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
for (
let v = rightScale.min;
let v = firstTick(rightScale);
v <= rightScale.max + rightScale.step / 2;
v += rightScale.step
) {
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { resolve } from '$app/paths';
// A hand-picked set of the prerendered city pages (see
// routes/weather/locations/city-names100.json) — instant navigation targets
// that double as SEO entry points.
const popularCities: { slug: string; label: string }[] = [
{ slug: 'london', label: 'London' },
{ slug: 'tokyo', label: 'Tokyo' },
{ slug: 'berlin', label: 'Berlin' },
{ slug: 'sydney', label: 'Sydney' },
{ slug: 'singapore', label: 'Singapore' },
{ slug: 'dubai', label: 'Dubai' },
{ slug: 'los-angeles', label: 'Los Angeles' },
{ slug: 'hong-kong', label: 'Hong Kong' },
{ slug: 'istanbul', label: 'Istanbul' },
{ slug: 'seoul', label: 'Seoul' },
{ slug: 'mexico-city', label: 'Mexico City' },
{ slug: 'sao-paulo', label: 'São Paulo' }
];
const forecastLinks = [
{ href: resolve('/weather/week'), label: '7-Day Forecast' },
{ href: resolve('/weather/compare'), label: 'Model Comparison' },
{ href: resolve('/weather/14-day'), label: '14-Day Forecast' },
{ href: resolve('/weather/historical'), label: 'Historical Weather' },
{ href: resolve('/weather/maps'), label: 'Weather Maps' }
];
const year = new Date().getFullYear();
</script>
<footer class="mt-16 border-t border-border bg-card/60">
<div class="mx-auto w-full max-w-[1536px] px-4 py-10 lg:px-8">
<div class="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
<!-- Brand -->
<div class="max-w-xs">
<div class="flex items-center gap-2">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-lg"
></span
>
<span class="text-lg font-bold tracking-tight">Drizz.li</span>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">
Fast, no-nonsense weather forecasts built on open data.
</p>
<p class="mt-3 text-xs text-muted-foreground">
Weather data by
<a
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://open-meteo.com"
target="_blank"
rel="noopener noreferrer">Open-Meteo</a
>
</p>
</div>
<!-- Forecasts -->
<nav aria-label="Forecasts">
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
Forecasts
</h3>
<ul class="mt-3 flex flex-col gap-2 text-sm">
{#each forecastLinks as link (link.href)}
<li>
<a
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
href={link.href}>{link.label}</a
>
</li>
{/each}
</ul>
</nav>
<!-- Popular locations (spans two columns of links) -->
<nav aria-label="Popular locations" class="lg:col-span-2">
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
Popular locations
</h3>
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
{#each popularCities as city (city.slug)}
<li>
<a
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/weather/week/[location]', { location: city.slug })}
>{city.label} weather</a
>
</li>
{/each}
</ul>
</nav>
</div>
<!-- Bottom bar: legal -->
<div
class="mt-10 flex flex-col items-start justify-between gap-3 border-t border-border/70 pt-5 text-xs text-muted-foreground sm:flex-row sm:items-center"
>
<span>© {year} Drizz.li</span>
<nav aria-label="Legal" class="flex flex-wrap items-center gap-x-5 gap-y-1">
<a class="underline-offset-2 hover:text-foreground hover:underline" href={resolve('/about')}
>About</a
>
<a
class="underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/legal/imprint')}>Imprint</a
>
<a
class="underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/legal/privacy')}>Privacy</a
>
<a
class="underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/legal/terms')}>Terms</a
>
</nav>
</div>
</div>
</footer>
@@ -26,6 +26,16 @@
location = value;
});
// Prerendered pages bake the DEFAULT location's flag into the HTML, and
// Svelte's hydration repairs text but not attributes — so on pages that
// never update the store (legal pages etc.) the stale flag would stick
// around next to the correct location name. Re-sync the src after mount.
let flagEl = $state<HTMLImageElement>();
$effect(() => {
const src = `/images/country-flags/${(location.country_code || 'united_nations').toLowerCase()}.svg`;
if (flagEl && !flagEl.src.endsWith(src)) flagEl.src = src;
});
const themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, string> = {
system: 'Theme: follow system',
@@ -76,6 +86,7 @@
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
>
<img
bind:this={flagEl}
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country}
@@ -131,35 +131,7 @@
{/each}
</nav>
<!-- About / legal links (hidden when collapsed; the pages stay reachable
via any expanded sidebar and the signup page footer) -->
{#if !collapsed}
<div class="px-4 py-3 text-[11px] leading-relaxed text-sidebar-foreground/60">
<a
class="hover:text-sidebar-foreground hover:underline"
href={resolve('/about')}
onclick={onMobileClose}>About</a
>
<span aria-hidden="true"> · </span>
<a
class="hover:text-sidebar-foreground hover:underline"
href={resolve('/legal/imprint')}
onclick={onMobileClose}>Imprint</a
>
<span aria-hidden="true"> · </span>
<a
class="hover:text-sidebar-foreground hover:underline"
href={resolve('/legal/privacy')}
onclick={onMobileClose}>Privacy</a
>
<span aria-hidden="true"> · </span>
<a
class="hover:text-sidebar-foreground hover:underline"
href={resolve('/legal/terms')}
onclick={onMobileClose}>Terms</a
>
</div>
{/if}
<!-- About / legal links moved to the page footer -->
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
{#if onToggle}
+59
View File
@@ -273,6 +273,65 @@ export interface EnsembleForecastResult {
hourlyUnitsFlat: Record<string, string>;
}
// ─── Error Humanizing ───────────────────────────────────────────────────────────
export interface FriendlyWeatherError {
/** Short, plain-language headline. */
title: string;
/** What the user can actually do about it. */
hint?: string;
/** The raw underlying message, for a collapsed "technical details" block. */
detail?: string;
}
/**
* Turns a fetch/API error into something a person can act on. The raw message
* (often API-speak like "No data is available for this location") is kept as
* `detail` so it can be shown collapsed.
*/
export function humanizeWeatherError(err: unknown): FriendlyWeatherError {
const raw = err instanceof Error ? err.message : String(err);
const msg = raw.toLowerCase();
if (
err instanceof TypeError ||
msg.includes('failed to fetch') ||
msg.includes('networkerror') ||
msg.includes('load failed') ||
msg.includes('network request failed')
) {
return {
title: "Couldn't reach the weather service",
hint: 'Check your internet connection and try again.',
detail: raw
};
}
if (
msg.includes('no data is available') ||
msg.includes('not available for this location') ||
msg.includes('out of allowed range') ||
msg.includes('coordinates')
) {
return {
title: 'No data for this location with the selected model',
hint: 'Regional weather models only cover their own area — "Best match" picks a suitable model automatically.',
detail: raw
};
}
if (msg.includes('invalid') || msg.includes('cannot be') || msg.includes('bad request')) {
return {
title: 'The weather service rejected the request',
hint: 'Try different settings, or switch the model back to "Best match".',
detail: raw
};
}
return {
title: 'Loading the weather data failed',
hint: 'Try again in a moment. If it keeps happening, switch the model to "Best match".',
detail: raw
};
}
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
// Fallback set when the caller does not specify which hourly variables it
+9 -3
View File
@@ -5,6 +5,7 @@
import { storedTheme } from '$lib/stores/settings';
import Footer from '$lib/components/navigation/footer.svelte';
import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
@@ -88,11 +89,16 @@
{#if fullBleed}
{@render children()}
{:else}
<!-- cap the content width on very large screens; generous bottom room
so the last chart/table never sits flush against the viewport edge -->
<div class="mx-auto w-full max-w-[1536px] pb-80">
<!-- cap the content width on very large screens; the footer below
gives the page its ending, so only modest bottom room is needed -->
<div class="mx-auto w-full max-w-[1536px] pb-24">
{@render children()}
</div>
<!-- full-bleed footer inside the scroll area (its own inner max-w),
cancelling main's padding so it sits flush with the edges -->
<div class="-mx-3 -mb-3 lg:-mx-8 lg:-mb-6">
<Footer />
</div>
{/if}
</main>
</div>
@@ -195,6 +195,8 @@
unit={panel.def.unit}
unitRight={panel.def.unitRight}
yMin={panel.def.yMin}
yPadTop={panel.def.yPadTop}
yPadBottom={panel.def.yPadBottom}
zeroBaseLeft={panel.def.zeroBaseLeft}
yMinRight={panel.def.yMinRight}
yMaxRight={panel.def.yMaxRight}
+96 -26
View File
@@ -14,11 +14,15 @@
import { ChartContainer } from '$lib/components/charts';
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
import {
type FriendlyWeatherError,
type WeekForecastResult,
fetchWeekForecast,
humanizeWeatherError
} from '$lib/services/weather';
import { defaultParameters } from '../../options';
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
import DailyCards from './DailyCards.svelte';
import DailyStripSticky from './DailyStripSticky.svelte';
import HourlyTable from './HourlyTable.svelte';
import MeteogramCharts from './MeteogramCharts.svelte';
@@ -74,8 +78,29 @@
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let loadError = $state<FriendlyWeatherError | null>(null);
let requestVersion = 0;
// bumped by the "Try again" button to re-run the fetch effect
let retryNonce = $state(0);
/** Back to the model that always has data (also what ModelSelector does). */
function resetToBestMatch() {
params.models = ['best_match'];
storedModel.set('best_match');
forecastDays = 7;
pastDays = 0;
}
// A request can succeed yet contain nothing usable: regional models return
// all-NaN outside their coverage area. Detect that so the page can say so
// instead of silently rendering an empty strip.
let noData = $derived.by((): boolean => {
const fd = fetchedDaily;
if (loading || !fd) return false;
return !fd.daily.temperature_2m_max.some(
(v, i) => v != null && !isNaN(v) && !(v === 0 && fd.daily.temperature_2m_min[i] === 0)
);
});
// 7 by default; the user can extend to the model's longer range (up to 16 days)
let forecastDays = $state(7);
@@ -103,6 +128,7 @@
const loc = location;
const modelList = params.models;
const requestVars = hourlyVars;
void retryNonce; // re-run on "Try again"
if (!mounted || !loc || !modelList?.length) return;
@@ -156,7 +182,7 @@
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loadError = humanizeWeatherError(err);
loading = false;
});
});
@@ -210,33 +236,77 @@
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load weather data: {loadError}
<div class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm">
<p class="font-semibold text-destructive">{loadError.title}</p>
{#if loadError.hint}
<p class="mt-0.5 text-destructive/90">{loadError.hint}</p>
{/if}
<div class="mt-2.5 flex flex-wrap gap-2">
<button
class="cursor-pointer rounded-md border border-destructive/40 bg-background px-3 py-1 text-xs font-semibold text-destructive transition-colors hover:bg-destructive/10"
onclick={() => retryNonce++}
>
Try again
</button>
{#if params.models?.[0] !== 'best_match'}
<button
class="cursor-pointer rounded-md border border-border bg-background px-3 py-1 text-xs font-semibold text-foreground transition-colors hover:bg-muted"
onclick={resetToBestMatch}
>
Switch to Best match
</button>
{/if}
</div>
{#if loadError.detail}
<details class="mt-2 text-xs text-destructive/70">
<summary class="cursor-pointer select-none">Technical details</summary>
<p class="mt-1 font-mono break-all">{loadError.detail}</p>
</details>
{/if}
</div>
{/if}
<!-- Desktop: the full day cards -->
<div class="hidden md:block">
<DailyCards
daily={fetchedDaily}
{selectedDay}
units={params}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
/>
</div>
{#if noData && !loadError}
<!-- the request succeeded but every value is NaN: the selected (regional)
model doesn't cover this location -->
<div
class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-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="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.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z"
/>
</svg>
<div class="min-w-0 flex-1">
<p class="font-semibold">No forecast data for this model here</p>
<p class="text-[13px] opacity-90">
The selected weather model doesn't cover {location.name} — regional models only provide data
inside their own area.
</p>
</div>
<button
class="cursor-pointer rounded-md border border-amber-500/50 bg-background/60 px-3 py-1 text-xs font-semibold transition-colors hover:bg-background"
onclick={resetToBestMatch}
>
Switch to Best match
</button>
</div>
{/if}
<!-- The sticky day strip, the hourly table AND the meteograms share this
wrapper, so the strip stays stuck for the entire page (it collapses
as it sticks on mobile; on md+ it's a slim always-compact bar under
the topbar, complementing the full cards above). timeline-scope
hoists the strip's sentinel view-timeline so the sticky strip (a
sibling of the sentinel) can scrub its collapse from it. -->
wrapper, so the strip stays stuck for the entire page: the full day
cards collapse into the compact strip as it sticks (on md+ the bar
docks under the topbar at its exact height). timeline-scope hoists
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
@@ -116,7 +116,7 @@
{#if canExtendPast && onExtendPast}
<button
type="button"
class="day-side mr-4 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="mr-4 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"
>
@@ -347,7 +347,7 @@
{#if daily && canExtend && onExtend}
<button
type="button"
class="day-side mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch 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 md:mt-5 md:mb-11 md:flex md:w-24"
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch 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 md:mt-5 md:mb-11 md:flex md:w-24"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
@@ -409,18 +409,10 @@
}
}
/* Keep the exact same layout at every size, just scale it: small screens get
the compact scale, and mid-size desktops (anything below ~1440p) a gentler
one so the cards don't look oversized on 1080p displays. */
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
@media (max-width: 768px) {
.day-card {
zoom: 0.72;
}
}
@media (min-width: 768px) and (max-width: 2200px) {
.day-card,
.day-side {
zoom: 0.85;
}
}
</style>
@@ -5,8 +5,14 @@
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, windIsSignificant } from './significance';
import { type FetchedDaily, type WeatherUnits } from './types';
import {
getSunshineColor,
getSunshinePercent,
precipIsSignificant,
sunIsSignificant,
windIsSignificant
} from './significance';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
interface Props {
daily: FetchedDaily | null;
@@ -30,9 +36,11 @@
onExtendPast
}: Props = $props();
// ─── Scroll-driven collapse (full → compact, mobile only) ───────────────────
// ─── Scroll-driven collapse (full cards → compact strip) ───────────────────
// All sizing derives from a single registered custom property `--strip-p`
// (0 = full cards, 1 = compact strip):
// (0 = full cards, 1 = compact strip). Mobile and desktop share the exact
// same mechanics — desktop just uses larger "full" tunables and collapses
// into a bar that matches the topbar height.
//
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
// scrub `--strip-p` natively from a view-timeline on the sentinel below —
@@ -40,36 +48,37 @@
// * 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.
//
// On md+ the strip is pinned to the compact state (no animation) and docks
// under the topbar as a slim always-sticky day picker.
let sentinelEl = $state<HTMLDivElement>();
let stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>();
let needsSnapFallback = $state(false);
let compact = $state(false);
let stuck = $state(false);
onMount(() => {
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).
// `stuck` fires the moment the strip pins (turns the bar opaque before any
// content can slide under it); `compact` snaps the cells once more than
// half of the sentinel band has scrolled past. The transitions smooth both.
const io = new IntersectionObserver(
(entries) => {
const e = entries[entries.length - 1];
stuck = e.intersectionRatio < 0.97;
compact = e.intersectionRatio < 0.5;
},
{ threshold: [0.25, 0.5, 0.75] }
{ threshold: [0.25, 0.5, 0.75, 0.97] }
);
io.observe(sentinelEl);
return () => io.disconnect();
});
// Start scrolled so the "Past" button sits just off the left edge (revealed by
// scrolling left), with the first day flush to the content edge — same as the
// desktop cards. Runs once per dataset.
// scrolling left), with the first day flush to the content edge. The days
// group has min-width:100%, so the row always overflows by exactly the past
// button — this works at every viewport size. Runs once per dataset.
let scrolledForRef: FetchedDaily | null = null;
$effect(() => {
const d = daily;
@@ -77,10 +86,18 @@
scrolledForRef = d;
const scroll = stripScrollEl;
const wrap = daysWrapEl;
requestAnimationFrame(() => {
// 12px = the strip's px-3 left padding, so the first day lands on the edge
// the delta form is self-correcting (a no-op once right), so apply after
// layout and once more after late-loading CSS/fonts settle — otherwise a
// sliver of the past button can stay visible on first paint
const apply = () => {
const padLeft = parseFloat(getComputedStyle(scroll).paddingLeft) || 0;
const firstCell = wrap.querySelector('.strip-cell') ?? wrap;
scroll.scrollLeft +=
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
firstCell.getBoundingClientRect().left - scroll.getBoundingClientRect().left - padLeft;
};
requestAnimationFrame(() => {
apply();
setTimeout(apply, 250);
});
});
</script>
@@ -96,26 +113,33 @@
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8"
class:js-snap={needsSnapFallback}
class:compact
class:stuck
>
<div class="strip-row flex overflow-x-auto px-3 py-2 md:py-1 lg:px-8" bind:this={stripScrollEl}>
<div class="strip-row flex overflow-x-auto px-3 lg:px-8" bind:this={stripScrollEl}>
{#if daily}
{#if canExtendPast && onExtendPast}
<!-- starts scrolled out of view (revealed by scrolling left); styled to
match the 15-days button on the other end -->
<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 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
onclick={onExtendPast}
aria-label="Load recent past days"
aria-label="Load the past 3 days"
title="Load the past 3 days"
>
<svg
class="h-5 w-5 md:h-4 md:w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 6l-6 6 6 6" />
</svg>
<span class="text-[9px] leading-tight font-semibold">Past</span>
<span class="inline-flex items-baseline gap-0.5">
<svg
class="h-3 w-3 translate-y-px"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 5l-7 7 7 7" />
</svg>
<span class="text-sm leading-none font-extrabold">3</span>
</span>
<span class="text-[9px] leading-tight font-semibold">past</span>
</button>
{/if}
@@ -129,13 +153,23 @@
{@const nightCode = daily.nightCodes?.[index] ?? wCode}
{@const precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]}
{@const gustMax = daily.daily.windgusts_10m_max[index]}
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const sunDuration = daily.daily.sunshine_duration[index]}
{@const daylightSec = Math.max(
0,
(daily.daily.sunset[index] ?? 0) - (daily.daily.sunrise[index] ?? 0)
)}
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
{@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))}
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button
type="button"
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 md:gap-0 md:pb-0 {selected
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 {selected
? 'border-primary bg-primary/10 ring-1 ring-primary/50'
: 'border-border/60 bg-card'}"
aria-pressed={selected}
@@ -144,7 +178,7 @@
<!-- weekday drifts to the left edge and the date fades in at the
right edge as the cards collapse (flex spacers driven by --rel) -->
<span
class="dow-row flex w-full items-baseline px-0.5 text-[11px] font-semibold tracking-wide whitespace-nowrap md:text-[10px] {selected
class="dow-row flex w-full items-baseline px-0.5 font-semibold tracking-wide whitespace-nowrap {selected
? 'text-primary'
: ''}"
>
@@ -158,24 +192,27 @@
</span>
<span
class="rel-label overflow-hidden text-[9px] leading-[1.25] whitespace-nowrap text-muted-foreground"
class="rel-label overflow-hidden leading-[1.25] whitespace-nowrap text-muted-foreground"
>
{getRelativeDayLabel(time, daily.timezone)}
</span>
<div class="icon-wrap relative">
<svg class="day-icon fill-foreground">
<use
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 -->
<svg
class="night-badge absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60"
>
<!-- Two centered rows: day + night icons, then day + night temps.
Both stay perfectly centered when compact (the night icon melts
away and the day icon re-centers on its own); when full, small
k-driven nudges line each temp up under its icon. -->
<div class="flex w-full items-center justify-center">
<div class="icon-wrap">
<svg class="day-icon fill-foreground">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode,
true
)}.svg#Layer_1"
></use>
</svg>
</div>
<svg class="night-icon self-end fill-foreground/60">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode,
@@ -185,9 +222,9 @@
</svg>
</div>
<div class="flex items-baseline gap-1 leading-none">
<div class="flex w-full items-baseline justify-center gap-0.5 leading-none">
<span
class="temp-max rounded-md py-0.5 font-extrabold tabular-nums md:py-px"
class="temp-max rounded-md font-extrabold tabular-nums"
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
>
{tempMax.toFixed(0)}°
@@ -200,40 +237,90 @@
<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 {lowPrecip ? 'opacity-40' : ''}">
<svg class="fill-sky-500" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
<!-- sunshine bar (desktop full cards only, like the old day cards) -->
<div
class="hidden w-full items-center gap-1 px-0.5 md:my-1 md:flex {lowSun
? 'opacity-45'
: ''}"
>
<svg class="shrink-0" width="14" height="14" style="fill:{sunColor}">
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
</span>
<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>
{windMax?.toFixed(0) ?? '-'}
</span>
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full"
style="width:{sunPct}%;background-color:{sunColor}"
></div>
</div>
<span class="font-medium">
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
</span>
</div>
<!-- precip + wind: stacked on mobile, one row on md+ (old card style) -->
<div
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">
<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 {lowWind ? 'opacity-40' : ''}">
{#if windDir != null && !isNaN(windDir)}
<span
class="hidden shrink-0 md:inline-flex md:-mr-1"
style="transform: {getWindArrowRotation(windDir)}"
>
<svg class="fill-muted-foreground" width="20" height="20">
<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">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{:else}
<svg class="fill-muted-foreground" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{/if}
<span class="whitespace-nowrap"
>{windMax?.toFixed(0) ?? '-'}<span class="hidden opacity-70 md:inline"
>-{gustMax?.toFixed(0) ?? '-'}</span
></span
>
</span>
</div>
</div>
</button>
{/if}
{/each}
{#if canExtend && onExtend}
<!-- shows the RESULTING range, not an increment: tapping switches the
whole forecast from 7 to 15 days -->
<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 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
onclick={onExtend}
aria-label="Load the longer-range forecast"
aria-label="Show the full 15-day forecast"
title="Show the full 15-day forecast"
>
<svg
class="h-5 w-5 md:h-4 md:w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14" />
</svg>
<span class="text-[9px] leading-tight font-semibold">15d</span>
<span class="inline-flex items-baseline gap-0.5">
<span class="text-sm leading-none font-extrabold">15</span>
<svg
class="h-3 w-3 translate-y-px"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</span>
<span class="text-[9px] leading-tight font-semibold">days</span>
</button>
{/if}
</div>
@@ -253,21 +340,90 @@
initial-value: 0;
}
/* 1 as soon as the strip pins — drives the bar chrome independently of the
collapse progress so nothing ever shows through a still-expanding bar. */
@property --stuck {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Tunables, shared by the strip and its sentinel: the collapse plays out
over exactly the cell-height difference (see .daystrip height below). */
over exactly the bar-height difference (see .daystrip height below). */
.sentinel,
.daystrip {
--cell-w-full: 76px;
--cell-w-min: 56px;
--cell-w-min: 58px;
--cell-h-full: 140px;
--cell-h-min: 62px;
--cell-h-min: 64px;
--icon-full: 44px;
--icon-min: 21px;
--pt-full: 7px;
--pt-min: 2px;
--gap-full: 8px;
--gap-min: 4px;
--collapse: calc(var(--cell-h-full) - var(--cell-h-min));
/* bar (strip-row) vertical padding */
--pad-full: 8px;
--pad-min: 8px;
/* fonts */
--dow-font-full: 11px;
--dow-font-min: 11px;
--rel-font-full: 9px;
--rel-font-min: 9px;
--tmax-font-full: 12px;
--tmax-font-min: 11px;
--tmin-font-full: 11px;
--tmin-font-min: 10px;
--tmax-padx-full: 6px;
--tmax-padx-min: 4px;
/* full state only: nudge each temp to sit under "its" icon */
--tmax-nudge: 0px;
--tmin-nudge: 5px;
/* cell bottom padding (roomy when full, tight when compact) */
--pb-full: 6px;
--pb-min: 6px;
/* extra scrub distance beyond the height difference — slows the collapse
down; the surplus just slides content under the (opaque) bar */
--collapse-extra: 0px;
--collapse: calc(
var(--cell-h-full) + 2 * var(--pad-full) - var(--cell-h-min) - 2 * var(--pad-min) +
var(--collapse-extra)
);
}
/* md+: same collapse, but the full state keeps the old desktop day-card
proportions, plays out over a longer scroll distance, and the compact
bar docks under the topbar (slightly taller than its 56px). */
@media (min-width: 768px) {
.sentinel,
.daystrip {
--cell-w-full: 120px;
--cell-h-full: 208px;
--cell-w-min: 64px;
--cell-h-min: 56px;
--icon-full: 80px;
--icon-min: 22px;
--pt-full: 10px;
--gap-full: 10px;
--gap-min: 6px;
--pad-min: 4px;
--dow-font-full: 13px;
--dow-font-min: 10px;
--rel-font-full: 11px;
--tmax-font-full: 17px;
--tmin-font-full: 15px;
--tmax-padx-full: 12px;
--tmax-nudge: 0px;
--tmin-nudge: 8px;
--pb-full: 10px;
--pb-min: 2px;
--collapse-extra: 60px;
}
.daystrip {
/* breathing room between the full cards and the table */
margin-bottom: 14px;
}
}
/* Invisible collapse band: the scroll distance over which the collapse
@@ -280,6 +436,7 @@
.daystrip {
--strip-p: 0;
--stuck: 0;
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
--k: calc(1 - var(--strip-p));
@@ -288,11 +445,13 @@
--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));
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
--pad: calc(var(--pad-min) + (var(--pad-full) - var(--pad-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 turns opaque almost as soon as the strip sticks, so
content never shows through it */
--chrome: clamp(0, calc(var(--strip-p) * 6), 1);
/* bar background turns opaque the moment the strip sticks (via --stuck),
so content never shows through it — even while the cells are still
large and the collapse has barely started */
--chrome: clamp(0, calc(var(--strip-p) * 6 + var(--stuck)), 1);
/* The sticky box keeps a CONSTANT height — only its contents shrink.
The collapse therefore never resizes the document (the large table
@@ -300,7 +459,7 @@
of scroll jank on mobile Chromium), and because the collapse distance
equals the height difference, the table slides up under the shrinking
bar in exact sync. The empty lower part is click-through. */
height: calc(var(--cell-h-full) + 16px);
height: calc(var(--cell-h-full) + 2 * var(--pad-full));
pointer-events: none;
contain: layout style;
/* own compositor layer: per-frame repaints stay isolated to the strip */
@@ -313,6 +472,7 @@
.strip-row {
pointer-events: auto;
gap: var(--gap);
padding-block: var(--pad);
background: color-mix(
in oklab,
var(--color-background) calc(var(--chrome) * 100%),
@@ -330,9 +490,13 @@
view-timeline: --daystrip-sentinel block;
}
.daystrip {
animation: strip-collapse linear both;
animation-timeline: --daystrip-sentinel;
animation-range: exit 0% exit 100%;
animation:
strip-collapse linear both,
strip-stuck linear both;
animation-timeline: --daystrip-sentinel, --daystrip-sentinel;
animation-range:
exit 0% exit 100%,
exit 0% exit 3%;
}
}
@keyframes strip-collapse {
@@ -340,39 +504,57 @@
--strip-p: 1;
}
}
@keyframes strip-stuck {
to {
--stuck: 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;
transition:
--strip-p 0.28s ease,
--stuck 0.15s ease;
}
.daystrip.js-snap.compact {
--strip-p: 1;
}
.daystrip.js-snap.stuck {
--stuck: 1;
}
@media (min-width: 768px) {
/* larger cards need a touch longer to feel smooth */
.daystrip.js-snap {
transition:
--strip-p 0.55s ease,
--stuck 0.15s ease;
}
}
@media (prefers-reduced-motion: reduce) {
.daystrip.js-snap {
transition: none;
}
}
/* md+: no collapse — the strip is a slim, always-compact day picker that
docks under the topbar, matching its h-14 (56px) height exactly. */
@media (min-width: 768px) {
.sentinel,
.daystrip {
--cell-h-min: 48px;
--icon-min: 20px;
--gap-min: 6px;
}
.daystrip {
--strip-p: 1;
animation: none;
height: 56px;
}
}
.strip-days {
gap: var(--gap);
/* fill the row so it overflows by exactly the past button, which starts
scrolled out of view and is revealed by scrolling left — even when the
day cells alone wouldn't overflow (wide desktop viewports) */
min-width: 100%;
}
/* Scroll containers clip at the PADDING edge, so a parked past button would
always leak a sliver across the row's left padding. Extending the days
group's left edge (only while the past button exists) moves max-scroll so
the button parks fully beyond the clip edge. */
.strip-side + .strip-days {
padding-left: calc(12px - var(--gap-min));
}
@media (min-width: 1024px) {
.strip-side + .strip-days {
padding-left: calc(32px - var(--gap-min));
}
}
.strip-cell,
.strip-side {
@@ -380,11 +562,32 @@
/* size tracks the collapse exactly; only the tap highlight eases */
transition:
border-color 0.15s,
background-color 0.15s;
background-color 0.15s,
translate 0.2s ease-out,
scale 0.2s ease-out,
box-shadow 0.2s ease-out;
}
/* The old day-card lift: hover raises the card slightly, the selected day a
touch more. Scaled by --k so the effect melts away as the strip compacts
(and never disturbs the slim bar); hover only where hover exists. */
.strip-cell[aria-pressed='true'] {
z-index: 10;
translate: 0 calc(-4px * var(--k));
scale: calc(1 + 0.04 * var(--k));
box-shadow: 0 5px 14px -4px rgba(0, 0, 0, calc(0.4 * var(--k)));
}
@media (hover: hover) {
.strip-cell:hover:not([aria-pressed='true']) {
z-index: 10;
translate: 0 calc(-3px * var(--k));
scale: calc(1 + 0.02 * var(--k));
box-shadow: 0 4px 10px -4px rgba(0, 0, 0, calc(0.3 * var(--k)));
}
}
.strip-cell {
width: var(--cell-w);
padding-top: var(--pt);
padding-bottom: calc(var(--pb-min) + (var(--pb-full) - var(--pb-min)) * var(--k));
}
/* Side buttons keep the compact width in BOTH states, so almost nothing to
the left of the first day changes size during the collapse — the first
@@ -402,14 +605,24 @@
width: var(--icon);
height: var(--icon);
}
.night-badge {
width: calc(var(--icon) * 0.44);
height: calc(var(--icon) * 0.44);
/* The night icon sits beside the day icon (slightly low, like a companion)
and melts away completely when compact so the day icon re-centers. */
.night-icon {
display: block;
width: calc(var(--icon) * 0.42 * var(--rel));
height: calc(var(--icon) * 0.42 * var(--rel));
opacity: var(--rel);
margin-left: calc(-4px * var(--rel));
margin-bottom: calc(6px * var(--rel));
}
/* weekday centered when full, pushed to the edges when compact */
.dow-row {
font-size: calc(var(--dow-font-min) + (var(--dow-font-full) - var(--dow-font-min)) * var(--k));
}
.dow-spacer {
flex-grow: var(--rel);
/* keep some outer share when compact so weekday + date sit near-centered
with a modest gap instead of being pushed to the cell edges */
flex-grow: calc(0.6 + 0.4 * var(--rel));
}
.dow-mid {
flex-grow: calc(1 - var(--rel));
@@ -421,19 +634,31 @@
opacity: calc(1 - var(--rel));
}
.rel-label {
font-size: calc(var(--rel-font-min) + (var(--rel-font-full) - var(--rel-font-min)) * var(--k));
opacity: var(--rel);
max-height: calc(14px * var(--rel));
}
.detail-row {
opacity: var(--detail);
max-height: calc(42px * var(--detail));
max-height: calc(52px * var(--detail));
}
.temp-max {
font-size: calc(11px + 1px * var(--k));
padding-inline: calc(3px + 3px * var(--k));
font-size: calc(
var(--tmax-font-min) + (var(--tmax-font-full) - var(--tmax-font-min)) * var(--k)
);
padding-inline: calc(
var(--tmax-padx-min) + (var(--tmax-padx-full) - var(--tmax-padx-min)) * var(--k)
);
padding-block: calc(2px + 1px * var(--k));
/* full: line up under the day icon */
transform: translateX(calc(var(--tmax-nudge) * var(--k)));
}
.temp-min {
font-size: calc(10px + 1px * var(--k));
font-size: calc(
var(--tmin-font-min) + (var(--tmin-font-full) - var(--tmin-font-min)) * var(--k)
);
/* full: line up under the night icon */
transform: translateX(calc(var(--tmin-nudge) * var(--k)));
}
.daystrip :global(.overflow-x-auto) {
@@ -300,8 +300,9 @@
? 'border-t border-border/50'
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
>
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
<!-- same title treatment as the compare / 14-day pages -->
<div class="mb-1 flex items-center justify-between px-3 lg:px-0">
<h4 class="truncate text-sm font-bold tracking-tight">
<span class="hidden lg:inline">{panel.title}</span>
<span class="lg:hidden">{panel.titleShort}</span>
</h4>
@@ -327,6 +328,8 @@
unit={panel.def.unit}
unitRight={panel.def.unitRight}
yMin={panel.def.yMin}
yPadTop={panel.def.yPadTop}
yPadBottom={panel.def.yPadBottom}
zeroBaseLeft={panel.def.zeroBaseLeft}
yMinRight={panel.def.yMinRight}
yMaxRight={panel.def.yMaxRight}
@@ -13,6 +13,25 @@ export function precipIsSignificant(sum: number | null, unit: string): boolean {
return (sum ?? 0) >= min;
}
/** Sunshine as a share of daylight, for the sun progress bar (0-100). */
export function getSunshinePercent(
sunshineSeconds: number | null,
daylightSeconds: number
): number {
if (!sunshineSeconds || daylightSeconds <= 0) return 0;
return Math.min(100, (sunshineSeconds / daylightSeconds) * 100);
}
/** Sun icon / bar colour by sunshine ratio: grey → pale gold → amber. */
export function getSunshineColor(sunshineSeconds: number | null, daylightSeconds: number): string {
if (daylightSeconds <= 0) return '#d1d5db';
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
if (ratio >= 0.7) return '#f59e0b';
if (ratio >= 0.45) return '#fbbf24';
if (ratio >= 0.1) return '#fcd34d';
return '#d1d5db';
}
export function windIsSignificant(
speed: number | null,
gust: number | null,
@@ -419,6 +419,9 @@ export interface PanelDef {
yMin?: number;
yMinRight?: number;
yMaxRight?: number;
/** Breathing room (in axis units) above / below the left-axis data range. */
yPadTop?: number;
yPadBottom?: number;
/** Whether the left axis should include zero (false for pressure) */
zeroBaseLeft: boolean;
hasPictograms: boolean;
@@ -496,6 +499,10 @@ export function buildPanelDef(
unit: leftKind ? unitForKind(leftKind, units) : '',
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
// temperature curves shouldn't touch the frame: guarantee headroom above
// the max and extra space below the min (extrema labels live there too)
yPadTop: leftKind === 'temp' ? 3 : undefined,
yPadBottom: leftKind === 'temp' ? 5 : undefined,
// temperature and pressure sit far from zero, so their axis is derived from
// the data range (a forced 0 baseline just wastes vertical space)
zeroBaseLeft: leftKind ? isZeroBased(leftKind) : true,