better cards, local weather codes
This commit is contained in:
@@ -7,9 +7,7 @@
|
|||||||
<p>
|
<p>
|
||||||
Drizz.li is free to use. These terms cover the optional supporter contribution: a small payment
|
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
|
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
|
we call it a contribution, features are unlocked in return.
|
||||||
between you and the provider named in the
|
|
||||||
<a href="/legal/imprint/">imprint</a>, and consumer protection rules apply to it.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>1. What you get</h2>
|
<h2>1. What you get</h2>
|
||||||
|
|||||||
@@ -89,4 +89,118 @@ export function getWeatherIconName(code: number, daytime: boolean): string {
|
|||||||
return `wi-${daytime ? 'day' : 'night'}-${name}`;
|
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;
|
export default weatherCodes;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
||||||
|
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
|
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
||||||
import DailyCards from './DailyCards.svelte';
|
import DailyCards from './DailyCards.svelte';
|
||||||
import DailyStripSticky from './DailyStripSticky.svelte';
|
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||||
import HourlyTable from './HourlyTable.svelte';
|
import HourlyTable from './HourlyTable.svelte';
|
||||||
@@ -50,13 +51,18 @@
|
|||||||
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
||||||
|
|
||||||
// Request only the hourly variables the table rows and meteograms actually
|
// Request only the hourly variables the table rows and meteograms actually
|
||||||
// show, so unused variables are never fetched.
|
// show, so unused variables are never fetched. weather_code is always
|
||||||
let hourlyVars = $derived(
|
// included: the day cards / strip derive their day- and night-period icons
|
||||||
neededHourlyApiVars(
|
// from the hourly codes locally.
|
||||||
|
let hourlyVars = $derived([
|
||||||
|
...new Set([
|
||||||
|
...neededHourlyApiVars(
|
||||||
$storedVariablePrefs.table,
|
$storedVariablePrefs.table,
|
||||||
$storedChartLayout.flatMap((p) => p.variables)
|
$storedChartLayout.flatMap((p) => p.variables)
|
||||||
)
|
),
|
||||||
);
|
'weather_code'
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
|
||||||
// the URL is the source of truth: location comes from the load function,
|
// the URL is the source of truth: location comes from the load function,
|
||||||
// which is also correct on hydrated prerendered pages. The persisted store
|
// which is also correct on hydrated prerendered pages. The persisted store
|
||||||
@@ -129,10 +135,21 @@
|
|||||||
daylightBands: result.daylightBands
|
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 = {
|
fetchedDaily = {
|
||||||
daily: result.daily,
|
daily: result.daily,
|
||||||
timezone: result.timezone,
|
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;
|
loading = false;
|
||||||
@@ -217,8 +234,10 @@
|
|||||||
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so
|
<!-- 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
|
the strip stays stuck (collapsing as it goes) while scrolling the table
|
||||||
and then releases exactly at the table's bottom, freeing the meteograms.
|
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). -->
|
The strip itself is hidden on md+ (the desktop cards above take over).
|
||||||
<div>
|
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}
|
{#if fetchedDaily}
|
||||||
<DailyStripSticky
|
<DailyStripSticky
|
||||||
daily={fetchedDaily}
|
daily={fetchedDaily}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
|
||||||
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
|
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -85,30 +86,6 @@
|
|||||||
if (ratio >= 0.1) return '#fcd34d';
|
if (ratio >= 0.1) return '#fcd34d';
|
||||||
return '#d1d5db';
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Shared filter: erodes the filled weather glyphs slightly so their
|
<!-- Shared filter: erodes the filled weather glyphs slightly so their
|
||||||
@@ -171,6 +148,8 @@
|
|||||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||||
{@const wCode = daily.daily.weather_code[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 sunDuration = daily.daily.sunshine_duration[index]}
|
||||||
{@const daylightSec = getDaylightSeconds(index)}
|
{@const daylightSec = getDaylightSeconds(index)}
|
||||||
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
|
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
|
||||||
@@ -216,7 +195,10 @@
|
|||||||
style="filter: url(#thin-day-icon)"
|
style="filter: url(#thin-day-icon)"
|
||||||
>
|
>
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
|
dayCode,
|
||||||
|
true
|
||||||
|
)}.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
@@ -226,7 +208,7 @@
|
|||||||
>
|
>
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
wCode,
|
nightCode,
|
||||||
false
|
false
|
||||||
)}.svg#Layer_1"
|
)}.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { precipIsSignificant, windIsSignificant } from './significance';
|
||||||
import { type FetchedDaily, type WeatherUnits } from './types';
|
import { type FetchedDaily, type WeatherUnits } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,63 +31,38 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
|
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
|
||||||
// 0 = full cards (at the top of the page), 1 = compact square strip (stuck).
|
// The strip collapses from full cards to a compact strip as it sticks. All
|
||||||
// The progress sentinel sits ABOVE the sticky strip, so the strip shrinking
|
// sizing derives from a single registered custom property `--strip-p` (0 =
|
||||||
// (which reflows the table BELOW it) never feeds back into the measurement.
|
// full, 1 = compact):
|
||||||
let progress = $state(0);
|
//
|
||||||
|
// * 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 sentinelEl = $state<HTMLDivElement>();
|
||||||
let stripScrollEl = $state<HTMLDivElement>();
|
let stripScrollEl = $state<HTMLDivElement>();
|
||||||
let daysWrapEl = $state<HTMLDivElement>();
|
let daysWrapEl = $state<HTMLDivElement>();
|
||||||
// Resolved in onMount (client only) so this never touches `window` during the
|
let needsSnapFallback = $state(false);
|
||||||
// prerender of city pages.
|
let compact = $state(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
scrollParent = findScrollParent(sentinelEl ?? null);
|
const scrubSupported =
|
||||||
const target: EventTarget = scrollParent;
|
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
|
||||||
target.addEventListener('scroll', onScroll, { passive: true });
|
if (scrubSupported || !sentinelEl) return;
|
||||||
window.addEventListener('resize', onScroll, { passive: true });
|
needsSnapFallback = true;
|
||||||
measure();
|
// Collapse once more than half of the sentinel band has scrolled past the
|
||||||
return () => {
|
// top; expands again on the same boundary (the transition smooths both).
|
||||||
target.removeEventListener('scroll', onScroll);
|
const io = new IntersectionObserver(
|
||||||
window.removeEventListener('resize', onScroll);
|
(entries) => {
|
||||||
};
|
const e = entries[entries.length - 1];
|
||||||
});
|
compact = e.intersectionRatio < 0.5;
|
||||||
|
},
|
||||||
// Re-measure once data (and therefore the strip height) changes.
|
{ threshold: [0.25, 0.5, 0.75] }
|
||||||
$effect(() => {
|
);
|
||||||
void daily;
|
io.observe(sentinelEl);
|
||||||
tick().then(measure);
|
return () => io.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start scrolled so the "Past" button sits just off the left edge (revealed by
|
// 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;
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- progress sentinel: 0-height marker just above the sticky strip -->
|
<!-- progress sentinel: an invisible 120px band (taking no layout space) just
|
||||||
<div bind:this={sentinelEl} aria-hidden="true"></div>
|
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
|
||||||
<div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
|
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 daily}
|
||||||
{#if canExtendPast && onExtendPast}
|
{#if canExtendPast && onExtendPast}
|
||||||
<button
|
<button
|
||||||
@@ -137,15 +117,19 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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)}
|
{#each daily.dailyDates as time, index (index)}
|
||||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||||
{@const wCode = daily.daily.weather_code[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 precipSum = daily.daily.precipitation_sum[index]}
|
||||||
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||||
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
|
{@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)}
|
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -156,19 +140,29 @@
|
|||||||
onclick={() => onSelectDay(time, index)}
|
onclick={() => onSelectDay(time, index)}
|
||||||
>
|
>
|
||||||
<span
|
<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()}<span
|
||||||
|
class="date-inline align-baseline font-medium tabular-nums text-muted-foreground"
|
||||||
|
> {formatZoned(time, daily.timezone, 'd')}</span
|
||||||
>
|
>
|
||||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
|
||||||
</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)}
|
{getRelativeDayLabel(time, daily.timezone)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg class="day-icon fill-foreground">
|
<svg class="day-icon fill-foreground">
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
|
dayCode,
|
||||||
|
true
|
||||||
|
)}.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
<!-- night companion icon, present in the full view, fades as it collapses -->
|
<!-- night companion icon, present in the full view, fades as it collapses -->
|
||||||
@@ -177,7 +171,7 @@
|
|||||||
>
|
>
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
wCode,
|
nightCode,
|
||||||
false
|
false
|
||||||
)}.svg#Layer_1"
|
)}.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
@@ -186,12 +180,12 @@
|
|||||||
|
|
||||||
<div class="flex items-baseline gap-1 leading-none">
|
<div class="flex items-baseline gap-1 leading-none">
|
||||||
<span
|
<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}"
|
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
||||||
>
|
>
|
||||||
{tempMax.toFixed(0)}°
|
{tempMax.toFixed(0)}°
|
||||||
</span>
|
</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)}°
|
{tempMin.toFixed(0)}°
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,13 +193,13 @@
|
|||||||
<div
|
<div
|
||||||
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
|
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">
|
<svg class="fill-sky-500" width="13" height="13">
|
||||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
||||||
</span>
|
</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">
|
<svg class="fill-muted-foreground" width="13" height="13">
|
||||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -241,29 +235,51 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Everything below is derived from a single custom property `--p` (0 = full
|
/* `--strip-p` is registered so it interpolates: scroll-driven keyframes scrub
|
||||||
cards, 1 = compact strip) set on `.daystrip` once per scroll frame. The
|
it continuously, and the snap fallback's `transition` eases it 0 ↔ 1.
|
||||||
browser resolves the calc()s natively, so a scroll frame is one property
|
Everything below derives from it via calc(), so a scroll frame is one
|
||||||
write instead of dozens of inline-style patches across every cell. */
|
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 {
|
.daystrip {
|
||||||
/* tunables */
|
/* tunables */
|
||||||
--cell-w: 64px;
|
--cell-w-full: 76px;
|
||||||
--cell-h-full: 134px;
|
--cell-w-min: 56px;
|
||||||
--cell-h-min: 64px;
|
--cell-h-full: 146px;
|
||||||
--icon-full: 40px;
|
--cell-h-min: 66px;
|
||||||
--icon-min: 20px;
|
--icon-full: 44px;
|
||||||
--pt-full: 6px;
|
--icon-min: 21px;
|
||||||
--pt-min: 1px;
|
--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) */
|
/* 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));
|
--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));
|
--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));
|
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
|
||||||
--rel: clamp(0, calc(1 - var(--p) * 2), 1);
|
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
|
||||||
--detail: clamp(0, calc(1 - var(--p) * 1.6), 1);
|
--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 */
|
/* 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
|
/* Opaque-background fade only — no backdrop-filter blur or animated
|
||||||
box-shadow (both are very expensive to repaint every scroll frame on
|
box-shadow (both are very expensive to repaint every scroll frame on
|
||||||
@@ -278,11 +294,48 @@
|
|||||||
transform: translateZ(0);
|
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-cell,
|
||||||
.strip-side {
|
.strip-side {
|
||||||
width: var(--cell-w);
|
width: var(--cell-w);
|
||||||
height: var(--cell-h);
|
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:
|
transition:
|
||||||
border-color 0.15s,
|
border-color 0.15s,
|
||||||
background-color 0.15s;
|
background-color 0.15s;
|
||||||
@@ -299,6 +352,19 @@
|
|||||||
height: calc(var(--icon) * 0.44);
|
height: calc(var(--icon) * 0.44);
|
||||||
opacity: var(--rel);
|
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 {
|
.rel-label {
|
||||||
opacity: var(--rel);
|
opacity: var(--rel);
|
||||||
max-height: calc(14px * var(--rel));
|
max-height: calc(14px * var(--rel));
|
||||||
@@ -307,6 +373,13 @@
|
|||||||
opacity: var(--detail);
|
opacity: var(--detail);
|
||||||
max-height: calc(42px * 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) {
|
.daystrip :global(.overflow-x-auto) {
|
||||||
scrollbar-width: none;
|
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;
|
daily: WeekDailyData;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
dailyDates: Date[];
|
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' => {
|
export const getTempUnit = (units: WeatherUnits): '°C' | '°F' => {
|
||||||
|
|||||||
Reference in New Issue
Block a user