diff --git a/src/routes/legal/terms/+page.svelte b/src/routes/legal/terms/+page.svelte index 9264773..7acde49 100644 --- a/src/routes/legal/terms/+page.svelte +++ b/src/routes/legal/terms/+page.svelte @@ -7,9 +7,7 @@

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 - imprint, and consumer protection rules apply to it. + we call it a contribution, features are unlocked in return.

1. What you get

diff --git a/src/routes/weather/utils/weather-codes.ts b/src/routes/weather/utils/weather-codes.ts index 46a19f1..a222a3a 100644 --- a/src/routes/weather/utils/weather-codes.ts +++ b/src/routes/weather/utils/weather-codes.ts @@ -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 = { 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(); + 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; diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 0670cc2..2a7acb2 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -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 @@ -
+ 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. --> +
{#if fetchedDaily} = 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; - } - + + -
-
+
+
{#if daily} {#if canExtendPast && onExtendPast}