better cards, local weather codes

This commit is contained in:
Vincent van der Wal
2026-08-01 10:11:06 +02:00
parent 315d4e2bdc
commit 2f86d86165
7 changed files with 344 additions and 126 deletions
+1 -3
View File
@@ -7,9 +7,7 @@
<p>
Drizz.li is free to use. These terms cover the optional supporter contribution: a small payment
that helps keep the project running and, as a thank-you, unlocks the supporter extras. Although
we call it a contribution, features are unlocked in return - so legally it is a paid agreement
between you and the provider named in the
<a href="/legal/imprint/">imprint</a>, and consumer protection rules apply to it.
we call it a contribution, features are unlocked in return.
</p>
<h2>1. What you get</h2>
+114
View File
@@ -89,4 +89,118 @@ export function getWeatherIconName(code: number, daytime: boolean): string {
return `wi-${daytime ? 'day' : 'night'}-${name}`;
}
// ─── Local day/night weather codes ──────────────────────────────────────────
// Open-meteo's daily weather_code is a plain numeric max over all 24 hourly
// codes (VariableDaily.swift: `.max(.weathercode)`) — there is no day/night
// split in the API, and numeric max has known flaws: one foggy hour wins the
// whole day (open-meteo issue #228), "slight showers" (80) outranks "heavy
// rain" (65), one overcast hour beats a mostly-clear day. The aggregation
// below derives a separate code for the daylight hours (sunrise→sunset) and
// the following night (sunset→next sunrise), ranking hazards by group first
// (thunder > freezing > snow > rain > drizzle) and picking the most frequent
// intensity within the winning group, with a persistence rule for fog and a
// mean (not max) for plain sky states.
// Only the code subset open-meteo actually emits (WeatherCode.swift) matters here.
const THUNDER = new Set([95, 96, 99]);
const FREEZING = new Set([56, 57, 66, 67]);
const SNOW = new Set([71, 73, 75, 77, 85, 86]);
const RAIN = new Set([61, 63, 65, 80, 81, 82]);
const DRIZZLE = new Set([51, 53, 55]);
const FOG = new Set([45, 48]);
// Showers / snow grains count as the same intensity as their steady
// counterparts when breaking frequency ties (fixes 80 "outranking" 65).
const INTENSITY_EQUIV: Record<number, number> = { 80: 61, 81: 63, 82: 65, 77: 71, 85: 71, 86: 75 };
/** Most frequent code; ties go to the more intense (then higher) code. */
function modeWithHighTiebreak(codes: number[]): number {
const counts = new Map<number, number>();
for (const c of codes) counts.set(c, (counts.get(c) ?? 0) + 1);
let best = codes[0];
let bestCount = -1;
for (const [code, count] of counts) {
const intensity = INTENSITY_EQUIV[code] ?? code;
const bestIntensity = INTENSITY_EQUIV[best] ?? best;
if (
count > bestCount ||
(count === bestCount &&
(intensity > bestIntensity || (intensity === bestIntensity && code > best)))
) {
best = code;
bestCount = count;
}
}
return best;
}
/** Aggregates one daypart's hourly codes into a single representative code. */
function daypartCode(codes: number[]): number | null {
const hours = codes.filter((c) => Number.isFinite(c));
const n = hours.length;
if (n === 0) return null;
// Hazards, worst group first: a single hour is enough to lead the icon
// (open-meteo's "don't hide hazards" philosophy, kept per-group).
for (const group of [THUNDER, FREEZING, SNOW, RAIN, DRIZZLE]) {
const hits = hours.filter((c) => group.has(c));
if (hits.length > 0) return modeWithHighTiebreak(hits);
}
// Fog needs persistence (≥2h and ≥¼ of the daypart) so one misty hour at
// dawn doesn't brand the whole day — the issue #228 complaint.
const fogHits = hours.filter((c) => FOG.has(c));
if (fogHits.length >= Math.max(2, Math.ceil(n / 4))) return modeWithHighTiebreak(fogHits);
// Sky states: mean, not max — one overcast hour shouldn't win. Short fog
// spells below the threshold count as overcast (3).
const sky = hours.map((c) => (FOG.has(c) ? 3 : c)).filter((c) => c >= 0 && c <= 3);
if (sky.length === 0) return null;
const mean = sky.reduce((a, b) => a + b, 0) / sky.length;
return Math.min(3, Math.max(0, Math.round(mean)));
}
export interface DayNightWeatherCodes {
/** Per daily index: code for sunrise→sunset, or null if no hourly data fell in the window. */
day: (number | null)[];
/** Per daily index: code for sunset→next sunrise (the night following that day). */
night: (number | null)[];
}
/**
* Splits hourly weather codes into per-day daylight and following-night
* buckets using the API's own sunrise/sunset timestamps, and aggregates each
* bucket. Timestamps share the response's epoch basis (hourly in ms, daily
* sunrise/sunset in seconds).
*/
export function computeDayNightWeatherCodes(
hourlyTimestampsMs: number[],
hourlyWeatherCodes: number[],
sunriseSec: number[],
sunsetSec: number[]
): DayNightWeatherCodes {
const days = sunriseSec.length;
const day: (number | null)[] = new Array(days).fill(null);
const night: (number | null)[] = new Array(days).fill(null);
if (hourlyWeatherCodes.length === 0) return { day, night };
for (let i = 0; i < days; i++) {
const rise = sunriseSec[i];
const set = sunsetSec[i];
if (!rise || !set || set <= rise) continue; // missing / polar edge cases
const nightEnd = sunriseSec[i + 1] || Infinity; // last day: whatever hours remain
const dayCodes: number[] = [];
const nightCodes: number[] = [];
for (let h = 0; h < hourlyTimestampsMs.length; h++) {
const t = hourlyTimestampsMs[h] / 1000;
if (t >= rise && t < set) dayCodes.push(hourlyWeatherCodes[h]);
else if (t >= set && t < nightEnd) nightCodes.push(hourlyWeatherCodes[h]);
}
day[i] = daypartCode(dayCodes);
night[i] = daypartCode(nightCodes);
}
return { day, night };
}
export default weatherCodes;
+29 -10
View File
@@ -17,6 +17,7 @@
import { type WeekForecastResult, fetchWeekForecast } 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';
@@ -50,13 +51,18 @@
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
// Request only the hourly variables the table rows and meteograms actually
// show, so unused variables are never fetched.
let hourlyVars = $derived(
neededHourlyApiVars(
$storedVariablePrefs.table,
$storedChartLayout.flatMap((p) => p.variables)
)
);
// show, so unused variables are never fetched. weather_code is always
// included: the day cards / strip derive their day- and night-period icons
// from the hourly codes locally.
let hourlyVars = $derived([
...new Set([
...neededHourlyApiVars(
$storedVariablePrefs.table,
$storedChartLayout.flatMap((p) => p.variables)
),
'weather_code'
])
]);
// the URL is the source of truth: location comes from the load function,
// which is also correct on hydrated prerendered pages. The persisted store
@@ -129,10 +135,21 @@
daylightBands: result.daylightBands
};
// Split the hourly codes into daylight / following-night buckets so
// the night badge shows the actual night conditions instead of a
// night-styled copy of the day icon.
const parts = computeDayNightWeatherCodes(
result.hourlyTimestamps,
result.hourly.weather_code,
result.daily.sunrise,
result.daily.sunset
);
fetchedDaily = {
daily: result.daily,
timezone: result.timezone,
dailyDates: result.dailyDates
dailyDates: result.dailyDates,
dayCodes: parts.day.map((c, i) => c ?? result.daily.weather_code[i]),
nightCodes: parts.night.map((c, i) => c ?? parts.day[i] ?? result.daily.weather_code[i])
};
loading = false;
@@ -217,8 +234,10 @@
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so
the strip stays stuck (collapsing as it goes) while scrolling the table
and then releases exactly at the table's bottom, freeing the meteograms.
The strip itself is hidden on md+ (the desktop cards above take over). -->
<div>
The strip itself is hidden on md+ (the desktop cards above take over).
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
daily={fetchedDaily}
@@ -5,6 +5,7 @@
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
interface Props {
@@ -85,30 +86,6 @@
if (ratio >= 0.1) return '#fcd34d';
return '#d1d5db';
}
// ─── "Is this metric worth highlighting?" thresholds ────────────────────────
// Below these, the sun / precip / wind bits are greyed out so a card at a
// glance only emphasises what's actually notable that day.
function sunIsSignificant(sunshineSeconds: number | null, daylightSeconds: number): boolean {
if (daylightSeconds <= 0) return false;
return (sunshineSeconds ?? 0) / daylightSeconds >= 0.1;
}
function precipIsSignificant(sum: number | null, unit: string): boolean {
const min = unit === 'mm' ? 0.1 : 0.005; // anything above a trace
return (sum ?? 0) >= min;
}
function windIsSignificant(speed: number | null, gust: number | null, unit: string): boolean {
// separate bars: sustained wind ~ a light breeze (~12 km/h), gusts a bit
// higher (~22 km/h). If EITHER is met, the whole wind readout is coloured.
const windMin = unit === 'ms' ? 3 : unit === 'mph' ? 7 : unit === 'kn' ? 6 : 12;
const gustMin = unit === 'ms' ? 6 : unit === 'mph' ? 14 : unit === 'kn' ? 12 : 22;
const s = speed != null && !isNaN(speed) ? speed : -Infinity;
const g = gust != null && !isNaN(gust) ? gust : -Infinity;
return s >= windMin || g >= gustMin;
}
</script>
<!-- Shared filter: erodes the filled weather glyphs slightly so their
@@ -171,6 +148,8 @@
{@const tempMax = daily.daily.temperature_2m_max[index]}
{@const tempMin = daily.daily.temperature_2m_min[index]}
{@const wCode = daily.daily.weather_code[index]}
{@const dayCode = daily.dayCodes?.[index] ?? wCode}
{@const nightCode = daily.nightCodes?.[index] ?? wCode}
{@const sunDuration = daily.daily.sunshine_duration[index]}
{@const daylightSec = getDaylightSeconds(index)}
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
@@ -216,7 +195,10 @@
style="filter: url(#thin-day-icon)"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode,
true
)}.svg#Layer_1"
></use>
</svg>
<svg
@@ -226,7 +208,7 @@
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
wCode,
nightCode,
false
)}.svg#Layer_1"
></use>
@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { onMount } from 'svelte';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, windIsSignificant } from './significance';
import { type FetchedDaily, type WeatherUnits } from './types';
interface Props {
@@ -30,63 +31,38 @@
}: Props = $props();
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
// 0 = full cards (at the top of the page), 1 = compact square strip (stuck).
// The progress sentinel sits ABOVE the sticky strip, so the strip shrinking
// (which reflows the table BELOW it) never feeds back into the measurement.
let progress = $state(0);
// The strip collapses from full cards to a compact strip as it sticks. All
// sizing derives from a single registered custom property `--strip-p` (0 =
// full, 1 = compact):
//
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
// scrub `--strip-p` natively from a view-timeline on the sentinel below —
// no JS runs during scroll at all, so there is no rAF frame-lag or jitter.
// * Everything else (Firefox, older Safari) snaps between the two states
// with a short CSS transition instead: an IntersectionObserver on the same
// sentinel toggles `.compact`. No per-frame scroll handler anywhere.
let sentinelEl = $state<HTMLDivElement>();
let stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>();
// Resolved in onMount (client only) so this never touches `window` during the
// prerender of city pages.
let scrollParent: HTMLElement | Window | null = $state(null);
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
let node = el?.parentElement ?? null;
while (node) {
const oy = getComputedStyle(node).overflowY;
if (oy === 'auto' || oy === 'scroll') return node;
node = node.parentElement;
}
return window;
}
// Distance (px) over which the collapse plays out once the strip sticks.
const RANGE = 120;
let ticking = false;
function measure() {
ticking = false;
if (!sentinelEl || !scrollParent) return;
const topRef = scrollParent instanceof Window ? 0 : scrollParent.getBoundingClientRect().top;
const top = sentinelEl.getBoundingClientRect().top - topRef;
progress = clamp(-top / RANGE);
}
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(measure);
}
let needsSnapFallback = $state(false);
let compact = $state(false);
onMount(() => {
scrollParent = findScrollParent(sentinelEl ?? null);
const target: EventTarget = scrollParent;
target.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
measure();
return () => {
target.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
});
// Re-measure once data (and therefore the strip height) changes.
$effect(() => {
void daily;
tick().then(measure);
const scrubSupported =
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
if (scrubSupported || !sentinelEl) return;
needsSnapFallback = true;
// Collapse once more than half of the sentinel band has scrolled past the
// top; expands again on the same boundary (the transition smooths both).
const io = new IntersectionObserver(
(entries) => {
const e = entries[entries.length - 1];
compact = e.intersectionRatio < 0.5;
},
{ threshold: [0.25, 0.5, 0.75] }
);
io.observe(sentinelEl);
return () => io.disconnect();
});
// Start scrolled so the "Past" button sits just off the left edge (revealed by
@@ -105,17 +81,21 @@
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
});
});
// All progress-driven sizing lives in CSS (via the single `--p` custom property
// set on the strip), so a scroll frame writes ONE value instead of re-patching
// height/padding/opacity inline styles on every cell.
</script>
<!-- progress sentinel: 0-height marker just above the sticky strip -->
<div bind:this={sentinelEl} aria-hidden="true"></div>
<!-- progress sentinel: an invisible 120px band (taking no layout space) just
above the sticky strip. Its exit across the scrollport top drives the
collapse — via view-timeline where supported, IntersectionObserver
otherwise. It sits above the strip so the strip shrinking (which reflows
the table below) never feeds back into the measurement. -->
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
<div class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" style="--p:{progress}">
<div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
<div
class="daystrip sticky -top-3 z-30 -mx-3 md:hidden"
class:js-snap={needsSnapFallback}
class:compact
>
<div class="strip-row flex overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
{#if daily}
{#if canExtendPast && onExtendPast}
<button
@@ -137,15 +117,19 @@
</button>
{/if}
<div class="flex gap-1.5" bind:this={daysWrapEl}>
<div class="strip-days flex" bind:this={daysWrapEl}>
{#each daily.dailyDates as time, index (index)}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const tempMax = daily.daily.temperature_2m_max[index]}
{@const tempMin = daily.daily.temperature_2m_min[index]}
{@const wCode = daily.daily.weather_code[index]}
{@const dayCode = daily.dayCodes?.[index] ?? wCode}
{@const nightCode = daily.nightCodes?.[index] ?? wCode}
{@const precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]}
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
{@const lowWind = !windIsSignificant(windMax, null, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button
type="button"
@@ -156,19 +140,29 @@
onclick={() => onSelectDay(time, index)}
>
<span
class="text-[11px] font-semibold tracking-wide {selected ? 'text-primary' : ''}"
class="text-[11px] font-semibold tracking-wide whitespace-nowrap {selected
? 'text-primary'
: ''}"
>
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}<span
class="date-inline align-baseline font-medium tabular-nums text-muted-foreground"
>&hairsp;{formatZoned(time, daily.timezone, 'd')}</span
>
</span>
<span class="rel-label overflow-hidden text-[9px] leading-none text-muted-foreground">
<span
class="rel-label overflow-hidden text-[9px] leading-[1.25] whitespace-nowrap text-muted-foreground"
>
{getRelativeDayLabel(time, daily.timezone)}
</span>
<div class="relative">
<svg class="day-icon fill-foreground">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode,
true
)}.svg#Layer_1"
></use>
</svg>
<!-- night companion icon, present in the full view, fades as it collapses -->
@@ -177,7 +171,7 @@
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
wCode,
nightCode,
false
)}.svg#Layer_1"
></use>
@@ -186,12 +180,12 @@
<div class="flex items-baseline gap-1 leading-none">
<span
class="rounded-md px-1.5 py-0.5 text-[12px] font-extrabold tabular-nums"
class="temp-max rounded-md py-0.5 font-extrabold tabular-nums"
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
>
{tempMax.toFixed(0)}°
</span>
<span class="text-[11px] font-semibold tabular-nums text-muted-foreground">
<span class="temp-min font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</span>
</div>
@@ -199,13 +193,13 @@
<div
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
>
<span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
<svg class="fill-sky-500" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1 {lowWind ? 'opacity-40' : ''}">
<svg class="fill-muted-foreground" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
@@ -241,29 +235,51 @@
</div>
<style>
/* Everything below is derived from a single custom property `--p` (0 = full
cards, 1 = compact strip) set on `.daystrip` once per scroll frame. The
browser resolves the calc()s natively, so a scroll frame is one property
write instead of dozens of inline-style patches across every cell. */
/* `--strip-p` is registered so it interpolates: scroll-driven keyframes scrub
it continuously, and the snap fallback's `transition` eases it 0 ↔ 1.
Everything below derives from it via calc(), so a scroll frame is one
property update resolved natively by the browser — no JS, no inline-style
patches across cells. */
@property --strip-p {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Invisible 120px collapse band: the distance over which the collapse plays
out. The negative margin removes it from layout so nothing shifts. */
.sentinel {
height: 120px;
margin-bottom: -120px;
pointer-events: none;
}
.daystrip {
/* tunables */
--cell-w: 64px;
--cell-h-full: 134px;
--cell-h-min: 64px;
--icon-full: 40px;
--icon-min: 20px;
--pt-full: 6px;
--pt-min: 1px;
--cell-w-full: 76px;
--cell-w-min: 56px;
--cell-h-full: 146px;
--cell-h-min: 66px;
--icon-full: 44px;
--icon-min: 21px;
--pt-full: 7px;
--pt-min: 2px;
--gap-full: 8px;
--gap-min: 4px;
--strip-p: 0;
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
--k: calc(1 - var(--p));
--k: calc(1 - var(--strip-p));
--cell-w: calc(var(--cell-w-min) + (var(--cell-w-full) - var(--cell-w-min)) * var(--k));
--cell-h: calc(var(--cell-h-min) + (var(--cell-h-full) - var(--cell-h-min)) * var(--k));
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
--rel: clamp(0, calc(1 - var(--p) * 2), 1);
--detail: clamp(0, calc(1 - var(--p) * 1.6), 1);
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
/* bar background/divider fade in once it starts collapsing */
--chrome: clamp(0, calc(var(--p) / 0.35), 1);
--chrome: clamp(0, calc(var(--strip-p) / 0.35), 1);
/* Opaque-background fade only — no backdrop-filter blur or animated
box-shadow (both are very expensive to repaint every scroll frame on
@@ -278,11 +294,48 @@
transform: translateZ(0);
}
/* Scrub path: the sentinel's exit across the scrollport top maps directly to
--strip-p 0→1 (the page wrapper hoists the timeline name via
`timeline-scope` so this sibling can reference it). */
@supports (animation-timeline: view()) and (timeline-scope: none) {
.sentinel {
view-timeline: --daystrip-sentinel block;
}
.daystrip {
animation: strip-collapse linear both;
animation-timeline: --daystrip-sentinel;
animation-range: exit 0% exit 100%;
}
}
@keyframes strip-collapse {
to {
--strip-p: 1;
}
}
/* Snap fallback: ease between the two end states instead of scrubbing.
(Browsers too old to register --strip-p simply switch instantly.) */
.daystrip.js-snap {
transition: --strip-p 0.28s ease;
}
.daystrip.js-snap.compact {
--strip-p: 1;
}
@media (prefers-reduced-motion: reduce) {
.daystrip.js-snap {
transition: none;
}
}
.strip-row,
.strip-days {
gap: var(--gap);
}
.strip-cell,
.strip-side {
width: var(--cell-w);
height: var(--cell-h);
/* size tracks scroll exactly (no transition); only the tap highlight eases */
/* size tracks the collapse exactly; only the tap highlight eases */
transition:
border-color 0.15s,
background-color 0.15s;
@@ -299,6 +352,19 @@
height: calc(var(--icon) * 0.44);
opacity: var(--rel);
}
/* Day-of-month slides in next to the weekday as the cards collapse
("MON" → "MON 12"), replacing the relative label that fades out. */
.date-inline {
display: inline-block;
overflow: hidden;
white-space: nowrap;
/* overflow≠visible makes an inline-block's baseline its bottom edge, which
would render the digits superscript; text-bottom restores baseline
alignment (same font size as the weekday, so descents match). */
vertical-align: text-bottom;
max-width: calc(20px * (1 - var(--rel)));
opacity: calc(1 - var(--rel));
}
.rel-label {
opacity: var(--rel);
max-height: calc(14px * var(--rel));
@@ -307,6 +373,13 @@
opacity: var(--detail);
max-height: calc(42px * var(--detail));
}
.temp-max {
font-size: calc(11px + 1px * var(--k));
padding-inline: calc(3px + 3px * var(--k));
}
.temp-min {
font-size: calc(10px + 1px * var(--k));
}
.daystrip :global(.overflow-x-auto) {
scrollbar-width: none;
@@ -0,0 +1,28 @@
// ─── "Is this metric worth highlighting?" thresholds ────────────────────────
// Shared by the desktop day cards and the mobile day strip. Below these, the
// sun / precip / wind readouts are greyed out so a card at a glance only
// emphasises what's actually notable that day.
export function sunIsSignificant(sunshineSeconds: number | null, daylightSeconds: number): boolean {
if (daylightSeconds <= 0) return false;
return (sunshineSeconds ?? 0) / daylightSeconds >= 0.1;
}
export function precipIsSignificant(sum: number | null, unit: string): boolean {
const min = unit === 'mm' ? 0.1 : 0.005; // anything above a trace
return (sum ?? 0) >= min;
}
export function windIsSignificant(
speed: number | null,
gust: number | null,
unit: string
): boolean {
// separate bars: sustained wind ~ a light breeze (~12 km/h), gusts a bit
// higher (~22 km/h). If EITHER is met, the whole wind readout is coloured.
const windMin = unit === 'ms' ? 3 : unit === 'mph' ? 7 : unit === 'kn' ? 6 : 12;
const gustMin = unit === 'ms' ? 6 : unit === 'mph' ? 14 : unit === 'kn' ? 12 : 22;
const s = speed != null && !isNaN(speed) ? speed : -Infinity;
const g = gust != null && !isNaN(gust) ? gust : -Infinity;
return s >= windMin || g >= gustMin;
}
@@ -19,6 +19,10 @@ export interface FetchedDaily {
daily: WeekDailyData;
timezone: string;
dailyDates: Date[];
/** Locally computed sunrise→sunset weather code per day (falls back to daily.weather_code). */
dayCodes?: number[];
/** Locally computed sunset→next-sunrise weather code per day. */
nightCodes?: number[];
}
export const getTempUnit = (units: WeatherUnits): '°C' | '°F' => {