From a0379a1fd6c8826e5a33de98ebce37096724ef4a Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Sat, 1 Aug 2026 11:38:02 +0200 Subject: [PATCH] strip more seamless and margin improvements --- src/lib/charts/CanvasChart.svelte | 54 ++- src/lib/components/navigation/footer.svelte | 118 +++++ src/lib/components/navigation/header.svelte | 11 + .../components/navigation/weather-nav.svelte | 30 +- src/lib/services/weather.ts | 59 +++ src/routes/+layout.svelte | 12 +- .../[location]/HistoricalMeteograms.svelte | 2 + .../weather/week/[location]/+page.svelte | 122 +++-- .../weather/week/[location]/DailyCards.svelte | 14 +- .../week/[location]/DailyStripSticky.svelte | 439 +++++++++++++----- .../week/[location]/MeteogramCharts.svelte | 7 +- .../weather/week/[location]/significance.ts | 19 + .../weather/week/[location]/variables.ts | 7 + 13 files changed, 706 insertions(+), 188 deletions(-) create mode 100644 src/lib/components/navigation/footer.svelte diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index f0a834a..2ca7f11 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -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 ) { diff --git a/src/lib/components/navigation/footer.svelte b/src/lib/components/navigation/footer.svelte new file mode 100644 index 0000000..8fb2e54 --- /dev/null +++ b/src/lib/components/navigation/footer.svelte @@ -0,0 +1,118 @@ + + + diff --git a/src/lib/components/navigation/header.svelte b/src/lib/components/navigation/header.svelte index 514a175..85ec8dc 100644 --- a/src/lib/components/navigation/header.svelte +++ b/src/lib/components/navigation/header.svelte @@ -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(); + $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 = { 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" > {location.country} - - {#if !collapsed} -
- About - - Imprint - - Privacy - - Terms -
- {/if} + {#if onToggle} diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts index 27a617a..f612b07 100644 --- a/src/lib/services/weather.ts +++ b/src/lib/services/weather.ts @@ -273,6 +273,65 @@ export interface EnsembleForecastResult { hourlyUnitsFlat: Record; } +// ─── 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 diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7007773..208e20d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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} - -
+ +
{@render children()}
+ +
+
+
{/if}
diff --git a/src/routes/weather/historical/[location]/HistoricalMeteograms.svelte b/src/routes/weather/historical/[location]/HistoricalMeteograms.svelte index bc0c02f..9d2d9b1 100644 --- a/src/routes/weather/historical/[location]/HistoricalMeteograms.svelte +++ b/src/routes/weather/historical/[location]/HistoricalMeteograms.svelte @@ -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} diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index a6a6c47..60000f7 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -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(null); + let loadError = $state(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 @@ (variableSidebarOpen = false)} /> {#if loadError} -
- Failed to load weather data: {loadError} +
+

{loadError.title}

+ {#if loadError.hint} +

{loadError.hint}

+ {/if} +
+ + {#if params.models?.[0] !== 'best_match'} + + {/if} +
+ {#if loadError.detail} +
+ Technical details +

{loadError.detail}

+
+ {/if}
{/if} - - + {#if noData && !loadError} + +
+ + + +
+

No forecast data for this model here

+

+ The selected weather model doesn't cover {location.name} — regional models only provide data + inside their own area. +

+
+ +
+ {/if} + 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. -->
{#if fetchedDaily} @@ -347,7 +347,7 @@ {#if daily && canExtend && onExtend} {/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)} {/if} {/each} {#if canExtend && onExtend} + {/if}
@@ -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: ''; + 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) { diff --git a/src/routes/weather/week/[location]/MeteogramCharts.svelte b/src/routes/weather/week/[location]/MeteogramCharts.svelte index 76d1e9b..c96840e 100644 --- a/src/routes/weather/week/[location]/MeteogramCharts.svelte +++ b/src/routes/weather/week/[location]/MeteogramCharts.svelte @@ -300,8 +300,9 @@ ? 'border-t border-border/50' : 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}" > -
-

+ +
+

{panel.titleShort}

@@ -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} diff --git a/src/routes/weather/week/[location]/significance.ts b/src/routes/weather/week/[location]/significance.ts index 663ad82..87989b0 100644 --- a/src/routes/weather/week/[location]/significance.ts +++ b/src/routes/weather/week/[location]/significance.ts @@ -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, diff --git a/src/routes/weather/week/[location]/variables.ts b/src/routes/weather/week/[location]/variables.ts index df53a1c..7a31216 100644 --- a/src/routes/weather/week/[location]/variables.ts +++ b/src/routes/weather/week/[location]/variables.ts @@ -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,