fix incons

This commit is contained in:
Vincent van der Wal
2026-08-03 11:05:34 +02:00
parent f66e259e32
commit 9fc17e7417
13 changed files with 384 additions and 39 deletions
+29 -1
View File
@@ -335,5 +335,33 @@
"cadence_varies": "variiert", "cadence_varies": "variiert",
"model_group_automatic": "Automatisch", "model_group_automatic": "Automatisch",
"model_group_reanalysis": "ECMWF-Reanalyse", "model_group_reanalysis": "ECMWF-Reanalyse",
"model_group_regional_reanalysis": "Regionale Reanalyse" "model_group_regional_reanalysis": "Regionale Reanalyse",
"wmo_0": "Klarer Himmel",
"wmo_1": "Überwiegend klar",
"wmo_2": "Teils bewölkt",
"wmo_3": "Bedeckt",
"wmo_45": "Nebel",
"wmo_48": "Gefrierender Nebel",
"wmo_51": "Leichter Nieselregen",
"wmo_53": "Mäßiger Nieselregen",
"wmo_55": "Starker Nieselregen",
"wmo_56": "Leichter gefrierender Nieselregen",
"wmo_57": "Starker gefrierender Nieselregen",
"wmo_61": "Leichter Regen",
"wmo_63": "Mäßiger Regen",
"wmo_65": "Starker Regen",
"wmo_66": "Leichter gefrierender Regen",
"wmo_67": "Starker gefrierender Regen",
"wmo_71": "Leichter Schneefall",
"wmo_73": "Mäßiger Schneefall",
"wmo_75": "Starker Schneefall",
"wmo_77": "Schneegriesel",
"wmo_80": "Leichte Regenschauer",
"wmo_81": "Mäßige Regenschauer",
"wmo_82": "Heftige Regenschauer",
"wmo_85": "Leichte Schneeschauer",
"wmo_86": "Starke Schneeschauer",
"wmo_95": "Gewitter",
"wmo_96": "Gewitter mit leichtem Hagel",
"wmo_99": "Gewitter mit starkem Hagel"
} }
+29 -1
View File
@@ -335,5 +335,33 @@
"cadence_varies": "varies", "cadence_varies": "varies",
"model_group_automatic": "Automatic", "model_group_automatic": "Automatic",
"model_group_reanalysis": "ECMWF reanalysis", "model_group_reanalysis": "ECMWF reanalysis",
"model_group_regional_reanalysis": "Regional reanalysis" "model_group_regional_reanalysis": "Regional reanalysis",
"wmo_0": "Clear sky",
"wmo_1": "Mainly clear",
"wmo_2": "Partly cloudy",
"wmo_3": "Overcast",
"wmo_45": "Fog",
"wmo_48": "Depositing rime fog",
"wmo_51": "Light drizzle",
"wmo_53": "Moderate drizzle",
"wmo_55": "Dense drizzle",
"wmo_56": "Light freezing drizzle",
"wmo_57": "Dense freezing drizzle",
"wmo_61": "Slight rain",
"wmo_63": "Moderate rain",
"wmo_65": "Heavy rain",
"wmo_66": "Light freezing rain",
"wmo_67": "Heavy freezing rain",
"wmo_71": "Slight snowfall",
"wmo_73": "Moderate snowfall",
"wmo_75": "Heavy snowfall",
"wmo_77": "Snow grains",
"wmo_80": "Slight rain showers",
"wmo_81": "Moderate rain showers",
"wmo_82": "Violent rain showers",
"wmo_85": "Slight snow showers",
"wmo_86": "Heavy snow showers",
"wmo_95": "Thunderstorm",
"wmo_96": "Thunderstorm with slight hail",
"wmo_99": "Thunderstorm with heavy hail"
} }
+29 -1
View File
@@ -335,5 +335,33 @@
"cadence_varies": "variable", "cadence_varies": "variable",
"model_group_automatic": "Automático", "model_group_automatic": "Automático",
"model_group_reanalysis": "Reanálisis del ECMWF", "model_group_reanalysis": "Reanálisis del ECMWF",
"model_group_regional_reanalysis": "Reanálisis regional" "model_group_regional_reanalysis": "Reanálisis regional",
"wmo_0": "Cielo despejado",
"wmo_1": "Mayormente despejado",
"wmo_2": "Parcialmente nublado",
"wmo_3": "Cubierto",
"wmo_45": "Niebla",
"wmo_48": "Niebla engelante",
"wmo_51": "Llovizna débil",
"wmo_53": "Llovizna moderada",
"wmo_55": "Llovizna intensa",
"wmo_56": "Llovizna engelante débil",
"wmo_57": "Llovizna engelante intensa",
"wmo_61": "Lluvia débil",
"wmo_63": "Lluvia moderada",
"wmo_65": "Lluvia intensa",
"wmo_66": "Lluvia engelante débil",
"wmo_67": "Lluvia engelante intensa",
"wmo_71": "Nevada débil",
"wmo_73": "Nevada moderada",
"wmo_75": "Nevada intensa",
"wmo_77": "Granos de nieve",
"wmo_80": "Chubascos débiles",
"wmo_81": "Chubascos moderados",
"wmo_82": "Chubascos violentos",
"wmo_85": "Chubascos de nieve débiles",
"wmo_86": "Chubascos de nieve intensos",
"wmo_95": "Tormenta",
"wmo_96": "Tormenta con granizo ligero",
"wmo_99": "Tormenta con granizo fuerte"
} }
+29 -1
View File
@@ -335,5 +335,33 @@
"cadence_varies": "variable", "cadence_varies": "variable",
"model_group_automatic": "Automatique", "model_group_automatic": "Automatique",
"model_group_reanalysis": "Réanalyse ECMWF", "model_group_reanalysis": "Réanalyse ECMWF",
"model_group_regional_reanalysis": "Réanalyse régionale" "model_group_regional_reanalysis": "Réanalyse régionale",
"wmo_0": "Ciel dégagé",
"wmo_1": "Plutôt dégagé",
"wmo_2": "Partiellement nuageux",
"wmo_3": "Couvert",
"wmo_45": "Brouillard",
"wmo_48": "Brouillard givrant",
"wmo_51": "Bruine faible",
"wmo_53": "Bruine modérée",
"wmo_55": "Bruine dense",
"wmo_56": "Bruine verglaçante faible",
"wmo_57": "Bruine verglaçante dense",
"wmo_61": "Pluie faible",
"wmo_63": "Pluie modérée",
"wmo_65": "Pluie forte",
"wmo_66": "Pluie verglaçante faible",
"wmo_67": "Pluie verglaçante forte",
"wmo_71": "Neige faible",
"wmo_73": "Neige modérée",
"wmo_75": "Neige forte",
"wmo_77": "Grésil",
"wmo_80": "Averses faibles",
"wmo_81": "Averses modérées",
"wmo_82": "Averses violentes",
"wmo_85": "Averses de neige faibles",
"wmo_86": "Averses de neige fortes",
"wmo_95": "Orage",
"wmo_96": "Orage avec grêle légère",
"wmo_99": "Orage avec forte grêle"
} }
+29 -1
View File
@@ -335,5 +335,33 @@
"cadence_varies": "variabile", "cadence_varies": "variabile",
"model_group_automatic": "Automatico", "model_group_automatic": "Automatico",
"model_group_reanalysis": "Rianalisi ECMWF", "model_group_reanalysis": "Rianalisi ECMWF",
"model_group_regional_reanalysis": "Rianalisi regionale" "model_group_regional_reanalysis": "Rianalisi regionale",
"wmo_0": "Cielo sereno",
"wmo_1": "Prevalentemente sereno",
"wmo_2": "Parzialmente nuvoloso",
"wmo_3": "Coperto",
"wmo_45": "Nebbia",
"wmo_48": "Nebbia gelata",
"wmo_51": "Pioviggine debole",
"wmo_53": "Pioviggine moderata",
"wmo_55": "Pioviggine intensa",
"wmo_56": "Pioviggine congelantesi debole",
"wmo_57": "Pioviggine congelantesi intensa",
"wmo_61": "Pioggia debole",
"wmo_63": "Pioggia moderata",
"wmo_65": "Pioggia forte",
"wmo_66": "Pioggia congelantesi debole",
"wmo_67": "Pioggia congelantesi forte",
"wmo_71": "Nevicata debole",
"wmo_73": "Nevicata moderata",
"wmo_75": "Nevicata forte",
"wmo_77": "Granuli di neve",
"wmo_80": "Rovesci deboli",
"wmo_81": "Rovesci moderati",
"wmo_82": "Rovesci violenti",
"wmo_85": "Rovesci di neve deboli",
"wmo_86": "Rovesci di neve forti",
"wmo_95": "Temporale",
"wmo_96": "Temporale con grandine leggera",
"wmo_99": "Temporale con grandine forte"
} }
@@ -3,7 +3,7 @@
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
interface Props { interface Props {
timestamps: number[]; timestamps: number[];
@@ -62,8 +62,9 @@
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`])); let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
</script> </script>
{#snippet weatherIcon(name: string, size: number = 24)} {#snippet weatherIcon(name: string, size: number = 24, description: string = '')}
<svg class="inline-block fill-foreground" width={size} height={size}> <svg class="inline-block fill-foreground" width={size} height={size}>
{#if description}<title>{description}</title>{/if}
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg> </svg>
{/snippet} {/snippet}
@@ -135,7 +136,11 @@
? 'border-l-2 border-l-primary/20' ? 'border-l-2 border-l-primary/20'
: ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}" : ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}"
> >
{@render weatherIcon(getWeatherIconName(code, day))} {@render weatherIcon(
getWeatherIconName(code, day),
24,
getWeatherDescription(code)
)}
</td> </td>
{/each} {/each}
</tr> </tr>
@@ -9,7 +9,7 @@
} from '$lib/services/weather'; } from '$lib/services/weather';
import { getColor, getTempStyle } from '../../utils/colors'; import { getColor, getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types'; import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
interface Props { interface Props {
@@ -202,7 +202,8 @@
>{formatZoned(date, timezone, 'd MMM')}</span >{formatZoned(date, timezone, 'd MMM')}</span
> >
<svg class="my-0.5" width="20" height="20" aria-hidden="true"> <svg class="my-0.5" width="20" height="20">
<title>{getWeatherDescription(daily.weather_code[i])}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
daily.weather_code[i], daily.weather_code[i],
@@ -0,0 +1,129 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
computeDayNightWeatherCodes,
getWeatherDescription,
getWeatherIconName
} from './weather-codes';
const ICON_DIR = join(process.cwd(), 'static/images/weather-icons');
// The codes open-meteo actually emits (WMO 4677 subset), with the family each
// one belongs to. The icon mapping used to be built for a different code table,
// which is how fog ended up showing hail and a hail thunderstorm a tornado -
// mismatches nothing in the type system could catch.
const OPEN_METEO_CODES: Record<number, { meaning: string; family: RegExp }> = {
0: { meaning: 'Clear sky', family: /clear/ },
1: { meaning: 'Mainly clear', family: /clear|cloudy/ },
2: { meaning: 'Partly cloudy', family: /cloudy/ },
3: { meaning: 'Overcast', family: /cloudy/ },
45: { meaning: 'Fog', family: /fog/ },
48: { meaning: 'Depositing rime fog', family: /fog/ },
51: { meaning: 'Light drizzle', family: /sprinkle|showers/ },
53: { meaning: 'Moderate drizzle', family: /sprinkle|rain/ },
55: { meaning: 'Dense drizzle', family: /sprinkle|rain/ },
56: { meaning: 'Light freezing drizzle', family: /rain-mix|sleet/ },
57: { meaning: 'Dense freezing drizzle', family: /rain-mix|sleet/ },
61: { meaning: 'Slight rain', family: /rain|sprinkle|showers/ },
63: { meaning: 'Moderate rain', family: /rain/ },
65: { meaning: 'Heavy rain', family: /rain/ },
66: { meaning: 'Light freezing rain', family: /rain-mix|sleet/ },
67: { meaning: 'Heavy freezing rain', family: /rain-mix|sleet/ },
71: { meaning: 'Slight snowfall', family: /snow/ },
73: { meaning: 'Moderate snowfall', family: /snow/ },
75: { meaning: 'Heavy snowfall', family: /snow/ },
77: { meaning: 'Snow grains', family: /snow/ },
80: { meaning: 'Slight rain showers', family: /showers|rain/ },
81: { meaning: 'Moderate rain showers', family: /showers|rain/ },
82: { meaning: 'Violent rain showers', family: /showers|rain/ },
85: { meaning: 'Slight snow showers', family: /snow/ },
86: { meaning: 'Heavy snow showers', family: /snow/ },
95: { meaning: 'Thunderstorm', family: /thunderstorm|storm-showers|lightning/ },
96: { meaning: 'Thunderstorm with slight hail', family: /thunderstorm|storm-showers|hail/ },
99: { meaning: 'Thunderstorm with heavy hail', family: /thunderstorm|storm-showers|hail/ }
};
const codes = Object.keys(OPEN_METEO_CODES).map(Number);
describe('getWeatherIconName', () => {
it.each(codes)('code %i resolves to icon files that exist', (code) => {
for (const daytime of [true, false]) {
const name = getWeatherIconName(code, daytime);
expect(existsSync(join(ICON_DIR, `${name}.svg`)), `${name}.svg missing`).toBe(true);
}
});
it.each(codes)('code %i uses a glyph that matches its meaning', (code) => {
const { meaning, family } = OPEN_METEO_CODES[code];
for (const daytime of [true, false]) {
const name = getWeatherIconName(code, daytime);
expect(name, `${code} (${meaning}) → ${name}`).toMatch(family);
}
});
it('never shows a tornado: open-meteo has no tornado code', () => {
const names = codes.flatMap((c) => [getWeatherIconName(c, true), getWeatherIconName(c, false)]);
expect(names.some((n) => n.includes('tornado'))).toBe(false);
});
it('distinguishes overcast from partly cloudy', () => {
expect(getWeatherIconName(3, true)).not.toBe(getWeatherIconName(2, true));
});
it('falls back to a clear glyph for codes it does not know', () => {
expect(getWeatherIconName(12345, true)).toBe('wi-day-clear');
});
});
describe('getWeatherDescription', () => {
it.each(codes)('code %i has a description', (code) => {
expect(getWeatherDescription(code).length).toBeGreaterThan(0);
});
it('returns nothing for unknown or missing codes', () => {
expect(getWeatherDescription(12345)).toBe('');
expect(getWeatherDescription(null)).toBe('');
expect(getWeatherDescription(undefined)).toBe('');
expect(getWeatherDescription(NaN)).toBe('');
});
});
describe('daily aggregation', () => {
const DAY = 24 * 3600;
// One synthetic day: sunrise 06:00, sunset 20:00, hourly codes from 00:00.
const hourly = (codes: number[]) => codes.map((_, i) => i * 3600 * 1000);
const run = (hourCodes: number[]) =>
computeDayNightWeatherCodes(hourly(hourCodes), hourCodes, [6 * 3600], [20 * 3600]).day[0];
const mostlyClear = (overrides: Record<number, number>) =>
Array.from({ length: DAY / 3600 }, (_, h) => overrides[h] ?? 0);
it('lets a single thundery hour lead the day, as before', () => {
expect(run(mostlyClear({ 18: 95 }))).toBe(95);
});
it('picks the plain thunderstorm when a lone 99 ties with a 95', () => {
// the reported case: one hour of 99 next to one hour of 95 used to escalate
// the whole day card to the most extreme code on the scale
expect(run(mostlyClear({ 18: 99, 19: 95 }))).toBe(95);
});
it('still shows 99 when it is the only thunder code', () => {
expect(run(mostlyClear({ 18: 99 }))).toBe(99);
});
it('follows frequency before severity within thunder', () => {
expect(run(mostlyClear({ 15: 99, 16: 99, 17: 95 }))).toBe(99);
});
it('keeps preferring the heavier code on a tie outside thunder', () => {
// 65 (heavy rain) over 80 (slight showers) - the open-meteo max-code flaw
expect(run(mostlyClear({ 12: 65, 13: 80 }))).toBe(65);
});
it('does not let one foggy hour brand the day', () => {
expect(run(mostlyClear({ 7: 45 }))).toBe(0);
});
});
+83 -23
View File
@@ -1,8 +1,10 @@
import * as m from '$lib/paraglide/messages';
const weatherCodes: Record<number, string> = { const weatherCodes: Record<number, string> = {
0: 'clear', 0: 'clear',
1: 'clear', 1: 'clear',
2: 'cloudy', 2: 'cloudy',
3: 'cloudy', 3: 'overcast',
4: 'fog', 4: 'fog',
5: 'fog', 5: 'fog',
10: 'fog', 10: 'fog',
@@ -30,26 +32,26 @@ const weatherCodes: Record<number, string> = {
42: 'rain', 42: 'rain',
43: 'sprinkle', 43: 'sprinkle',
44: 'rain', 44: 'rain',
45: 'hail', 45: 'fog',
46: 'hail', 46: 'hail',
47: 'snow', 47: 'snow',
48: 'snow', 48: 'fog',
50: 'sprinkle', 50: 'sprinkle',
51: 'sprinkle', 51: 'sprinkle',
52: 'rain', 52: 'rain',
53: 'rain', 53: 'sprinkle',
54: 'sprinkle', 54: 'sprinkle',
55: 'rain', 55: 'rain',
56: 'rain-mix', 56: 'rain-mix',
57: 'sprinkle', 57: 'rain-mix',
58: 'rain', 58: 'rain',
60: 'sprinkle', 60: 'sprinkle',
61: 'sprinkle', 61: 'sprinkle',
62: 'rain', 62: 'rain',
63: 'rain', 63: 'rain',
64: 'hail', 64: 'hail',
65: 'hail', 65: 'rain',
66: 'hail', 66: 'rain-mix',
67: 'rain-mix', 67: 'rain-mix',
68: 'rain-mix', 68: 'rain-mix',
70: 'snow', 70: 'snow',
@@ -61,13 +63,13 @@ const weatherCodes: Record<number, string> = {
76: 'snow', 76: 'snow',
77: 'snow', 77: 'snow',
78: 'snow', 78: 'snow',
80: 'rain', 80: 'showers',
81: 'sprinkle', 81: 'showers',
82: 'rain', 82: 'rain',
83: 'rain', 83: 'rain',
84: 'storm-showers', 84: 'storm-showers',
85: 'rain-mix', 85: 'snow',
86: 'rain-mix', 86: 'snow',
87: 'rain-mix', 87: 'rain-mix',
89: 'hail', 89: 'hail',
90: 'lightning', 90: 'lightning',
@@ -77,18 +79,68 @@ const weatherCodes: Record<number, string> = {
94: 'lightning', 94: 'lightning',
95: 'thunderstorm', 95: 'thunderstorm',
96: 'thunderstorm', 96: 'thunderstorm',
99: 'tornado' 99: 'storm-showers'
}; };
// These conditions ship only as a single neutral glyph (no day/night variant). // Conditions that ship as a single neutral glyph (no day/night variant). The
const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']); // file is not always wi-<name>: 'overcast' uses the flat cloud, which keeps it
// distinct from 'cloudy' (code 2), whose glyph carries a sun or moon.
const NEUTRAL_ICONS: Record<string, string> = {
'snowflake-cold': 'wi-snowflake-cold',
'strong-wind': 'wi-strong-wind',
dust: 'wi-dust',
tornado: 'wi-tornado',
overcast: 'wi-cloudy'
};
export function getWeatherIconName(code: number, daytime: boolean): string { export function getWeatherIconName(code: number, daytime: boolean): string {
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear'; const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`; const neutral = NEUTRAL_ICONS[name];
if (neutral) return neutral;
return `wi-${daytime ? 'day' : 'night'}-${name}`; return `wi-${daytime ? 'day' : 'night'}-${name}`;
} }
// Plain-language name for each code open-meteo actually emits (WMO 4677 subset),
// used as the hover title on the pictograms. A glyph alone is ambiguous - the
// hail-thunderstorm swirl in particular reads as something far more dramatic
// than "thunderstorm with heavy hail".
const WMO_DESCRIPTIONS: Record<number, () => string> = {
0: m.wmo_0,
1: m.wmo_1,
2: m.wmo_2,
3: m.wmo_3,
45: m.wmo_45,
48: m.wmo_48,
51: m.wmo_51,
53: m.wmo_53,
55: m.wmo_55,
56: m.wmo_56,
57: m.wmo_57,
61: m.wmo_61,
63: m.wmo_63,
65: m.wmo_65,
66: m.wmo_66,
67: m.wmo_67,
71: m.wmo_71,
73: m.wmo_73,
75: m.wmo_75,
77: m.wmo_77,
80: m.wmo_80,
81: m.wmo_81,
82: m.wmo_82,
85: m.wmo_85,
86: m.wmo_86,
95: m.wmo_95,
96: m.wmo_96,
99: m.wmo_99
};
/** Localized condition text for a weather code; '' for codes we have no name for. */
export function getWeatherDescription(code: number | null | undefined): string {
if (code == null || !Number.isFinite(code)) return '';
return WMO_DESCRIPTIONS[code]?.() ?? '';
}
// ─── Local day/night weather codes ────────────────────────────────────────── // ─── Local day/night weather codes ──────────────────────────────────────────
// Open-meteo's daily weather_code is a plain numeric max over all 24 hourly // 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 // codes (VariableDaily.swift: `.max(.weathercode)`) — there is no day/night
@@ -113,8 +165,17 @@ const FOG = new Set([45, 48]);
// counterparts when breaking frequency ties (fixes 80 "outranking" 65). // 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 }; 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 { * Most frequent code; ties go to the more intense (then higher) code, or with
* `preferLower` to the least intense one.
*
* Thunder is the one group that ties downwards. Within it the code only
* describes how much hail comes with the storm, and letting the worst of them
* win a coin-flip tie is how a single hour of 99 used to brand a whole day as
* the most extreme thing on the scale. Elsewhere the heavier code winning a tie
* is the point (heavy rain over slight showers).
*/
function modeWithHighTiebreak(codes: number[], preferLower = false): number {
const counts = new Map<number, number>(); const counts = new Map<number, number>();
for (const c of codes) counts.set(c, (counts.get(c) ?? 0) + 1); for (const c of codes) counts.set(c, (counts.get(c) ?? 0) + 1);
let best = codes[0]; let best = codes[0];
@@ -122,11 +183,10 @@ function modeWithHighTiebreak(codes: number[]): number {
for (const [code, count] of counts) { for (const [code, count] of counts) {
const intensity = INTENSITY_EQUIV[code] ?? code; const intensity = INTENSITY_EQUIV[code] ?? code;
const bestIntensity = INTENSITY_EQUIV[best] ?? best; const bestIntensity = INTENSITY_EQUIV[best] ?? best;
if ( const winsTie = preferLower
count > bestCount || ? intensity < bestIntensity || (intensity === bestIntensity && code < best)
(count === bestCount && : intensity > bestIntensity || (intensity === bestIntensity && code > best);
(intensity > bestIntensity || (intensity === bestIntensity && code > best))) if (count > bestCount || (count === bestCount && winsTie)) {
) {
best = code; best = code;
bestCount = count; bestCount = count;
} }
@@ -144,7 +204,7 @@ function daypartCode(codes: number[]): number | null {
// (open-meteo's "don't hide hazards" philosophy, kept per-group). // (open-meteo's "don't hide hazards" philosophy, kept per-group).
for (const group of [THUNDER, FREEZING, SNOW, RAIN, DRIZZLE]) { for (const group of [THUNDER, FREEZING, SNOW, RAIN, DRIZZLE]) {
const hits = hours.filter((c) => group.has(c)); const hits = hours.filter((c) => group.has(c));
if (hits.length > 0) return modeWithHighTiebreak(hits); if (hits.length > 0) return modeWithHighTiebreak(hits, group === THUNDER);
} }
// Fog needs persistence (≥2h and ≥¼ of the daypart) so one misty hour at // Fog needs persistence (≥2h and ≥¼ of the daypart) so one misty hour at
@@ -6,7 +6,7 @@
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance'; import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types'; import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
@@ -196,6 +196,7 @@
height="100px" height="100px"
style="filter: url(#thin-day-icon)" style="filter: url(#thin-day-icon)"
> >
<title>{getWeatherDescription(dayCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode, dayCode,
@@ -208,6 +209,7 @@
width="42px" width="42px"
height="42px" height="42px"
> >
<title>{getWeatherDescription(nightCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode, nightCode,
@@ -7,7 +7,7 @@
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { import {
getSunshineColor, getSunshineColor,
getSunshinePercent, getSunshinePercent,
@@ -276,6 +276,7 @@
<div class="icon-row flex w-full items-center justify-center"> <div class="icon-row flex w-full items-center justify-center">
<div class="icon-wrap"> <div class="icon-wrap">
<svg class="day-icon fill-foreground"> <svg class="day-icon fill-foreground">
<title>{getWeatherDescription(dayCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
dayCode, dayCode,
@@ -285,6 +286,7 @@
</svg> </svg>
</div> </div>
<svg class="night-icon self-end fill-foreground/60"> <svg class="night-icon self-end fill-foreground/60">
<title>{getWeatherDescription(nightCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode, nightCode,
@@ -16,7 +16,7 @@
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { import {
type FetchedDaily, type FetchedDaily,
type FetchedHourly, type FetchedHourly,
@@ -425,8 +425,9 @@
}); });
</script> </script>
{#snippet weatherIcon(name: string, size: number = 16)} {#snippet weatherIcon(name: string, size: number = 16, description: string = '')}
<svg class="inline-block fill-foreground" width={size} height={size}> <svg class="inline-block fill-foreground" width={size} height={size}>
{#if description}<title>{description}</title>{/if}
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg> </svg>
{/snippet} {/snippet}
@@ -678,7 +679,11 @@
!cellData[i + 1].isDaytime} !cellData[i + 1].isDaytime}
> >
{#if finite(wCode)} {#if finite(wCode)}
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)} {@render weatherIcon(
getWeatherIconName(wCode, cell.isDaytime),
iconPx,
getWeatherDescription(wCode)
)}
{/if} {/if}
</td> </td>
{/each} {/each}
@@ -9,7 +9,7 @@
import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities'; import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities';
import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather'; import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import type { UnitPrefs } from '$lib/stores/settings'; import type { UnitPrefs } from '$lib/stores/settings';
@@ -113,6 +113,7 @@
> >
<svg class="shrink-0 fill-foreground/80" width="34px" height="34px"> <svg class="shrink-0 fill-foreground/80" width="34px" height="34px">
{#if day} {#if day}
<title>{getWeatherDescription(day.weatherCode)}</title>
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
day.weatherCode, day.weatherCode,