better cards, local weather codes
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user