Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions public/api/widget-config/w-weather-uv
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"widgetSize":"small","widgetConfig":{"city":"Seville, Andalusia, Spain","lat":37.3891,"lon":-5.9845,"units":"c"}}
1 change: 1 addition & 0 deletions public/api/widget-data/w-weather-uv
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"temp":31,"usedFeels":false,"units":"c","code":0,"isDay":true,"city":"Seville, Spain","alerts":[{"kind":"uv","value":8,"band":"veryHigh","level":3}]}
8 changes: 8 additions & 0 deletions public/js/icon-set.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,14 @@ export const ICONS = {
accentCut: [{ ...shape(CLOUD), grow: T.G }],
},

uv: {
shapes: [circle(12, 12, 5, 'accent'), line('M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1 1M18 18l1 1M5 19l1-1M18 6l1-1')],
},
air: {
inkMask: false,
shapes: [line('M3 6q3-2 6 0t6 0t6 0'), line('M3 12q3-2 6 0t6 0t6 0', 'accent'), line('M3 18q3-2 6 0t6 0t6 0')],
},

edit: { shapes: [shape('M14 5l5 5L9 20H4v-5z'), line('M12 7l5 5', 'cut')] },
chevrons: { shapes: [line('M8 9l4-4 4 4'), line('M8 15l4 4 4-4')] },
back: { shapes: [line('M15 5l-7 7 7 7')] },
Expand Down
27 changes: 26 additions & 1 deletion public/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,38 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla
wheelWin = null;
}
};
/* Keys go to the focused document, not the hovered frame. Hand Escape to a
hovered bundled page, so it can dismiss its hover content. A frame never
matches :hover while the pointer is inside it. */
let hovered = false;
const enter = () => {
hovered = true;
};
const leave = () => {
hovered = false;
};
const forwardEscape = e => {
if (e.key !== 'Escape' || !hovered) return;
try {
const win = ifr.contentWindow;
win?.document.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
} catch {}
};
ifr.addEventListener('load', applyDir);
ifr.addEventListener('load', applyZoom);
if (zoomable) ifr.addEventListener('load', attachWheel);
if (zoomable) {
ifr.addEventListener('load', attachWheel);
ifr.addEventListener('pointerenter', enter);
ifr.addEventListener('pointerleave', leave);
document.addEventListener('keydown', forwardEscape);
}
cleanups.push(() => {
ifr.removeEventListener('load', applyDir);
ifr.removeEventListener('load', applyZoom);
ifr.removeEventListener('load', attachWheel);
ifr.removeEventListener('pointerenter', enter);
ifr.removeEventListener('pointerleave', leave);
document.removeEventListener('keydown', forwardEscape);
try {
if (wheelWin) wheelWin.removeEventListener('wheel', forwardWheel);
} catch {}
Expand Down
142 changes: 134 additions & 8 deletions public/widgets/weather/data.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* Open-Meteo (keyless) or OpenWeather (free API key). Returns
{ temp, units, code, isDay, usedFeels, city }, where code is the WMO weather
code for both providers. The city search always uses Open-Meteo's geocoder. */
{ temp, units, code, condition?, isDay, usedFeels, city, alerts? }, where code is the
WMO weather code for both providers. condition names sand or volcanic ash,
which share a WMO code with dust or smoke. The city search always uses Open-Meteo's geocoder.
alerts lists UV and air quality readings at or above their warning level, worst first. */

async function geocode(ctx) {
const q = String(ctx.config.cityQuery || ctx.config.city || '').trim();
Expand All @@ -12,7 +14,7 @@ async function geocode(ctx) {
const options = ((r.data && r.data.results) || []).map(p => ({
value: [p.name, p.admin1, p.country].filter(Boolean).join(', '),
label: [p.name, p.admin1, p.country].filter(Boolean).join(', '),
set: { lat: p.latitude, lon: p.longitude },
set: { lat: p.latitude, lon: p.longitude, countryCode: p.country_code },
}));
return { options };
}
Expand Down Expand Up @@ -47,25 +49,31 @@ async function openMeteo(ctx, lat, lon, units) {
};
}

const OW_ATMOSPHERE = { 701: 10, 711: 4, 721: 5, 731: 8, 741: 45, 751: 7, 761: 6, 762: 4, 771: 18, 781: 19 };
const OW_CONDITION = { 751: 'sand', 762: 'ash' };

/* OpenWeather condition id to the WMO code the page renders. */
function wmoFromOpenWeather(id) {
if (id >= 200 && id < 300) return 95;
if (id >= 300 && id < 400) return 51;
if (id === 511) return 66;
if (id >= 520 && id < 600) return 80;
if (id >= 500 && id < 600) return 61;
if (id >= 611 && id <= 613) return 79;
if (id === 615 || id === 616) return 68;
if (id >= 620 && id < 700) return 85;
if (id >= 600 && id < 700) return 71;
if (id >= 700 && id < 800) return 45;
if (id >= 700 && id < 800) return OW_ATMOSPHERE[id] ?? 45;
if (id === 800) return 0;
if (id === 801) return 1;
if (id === 802) return 2;
return 3;
}

const owKey = config => String(config.owKey || '').trim();

async function openWeather(ctx, lat, lon, units) {
const key = String(ctx.config.owKey || '').trim();
if (!key) ctx.fail('Enter an OpenWeather API key.', { kind: ctx.KIND.INVALID });
const key = owKey(ctx.config);
const url =
'https://api.openweathermap.org/data/2.5/weather' +
`?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}` +
Expand All @@ -89,10 +97,119 @@ async function openWeather(ctx, lat, lon, units) {
real,
feels: d.main.feels_like != null ? d.main.feels_like : real,
code: wmoFromOpenWeather(w.id),
condition: OW_CONDITION[w.id],
isDay,
};
}

const EAQI_CODES = new Set(
'AT BE BG HR CY CZ DK EE FI FR DE GR HU IS IE IT LV LI LT LU MT NL NO PL PT RO SK SI ES SE CH GB RE GP MQ GF YT MF AX'.split(
' ',
),
);

/* For places saved without a country code. Must match the geocoder's English country names. A mismatch silently picks the US index. */
const EAQI_COUNTRIES = new Set([
'Austria',
'Belgium',
'Bulgaria',
'Croatia',
'Cyprus',
'Czechia',
'Czech Republic',
'Denmark',
'Estonia',
'Finland',
'France',
'Germany',
'Greece',
'Hungary',
'Iceland',
'Ireland',
'Italy',
'Latvia',
'Liechtenstein',
'Lithuania',
'Luxembourg',
'Malta',
'Netherlands',
'The Netherlands',
'Norway',
'Poland',
'Portugal',
'Romania',
'Slovakia',
'Slovenia',
'Spain',
'Sweden',
'Switzerland',
'United Kingdom',
]);

function airScale(config) {
if (typeof config.countryCode === 'string' && config.countryCode) {
return EAQI_CODES.has(config.countryCode.toUpperCase()) ? 'eu' : 'us';
}
const country = String(config.city || '')
.split(',')
.pop()
.trim();
return EAQI_COUNTRIES.has(country) ? 'eu' : 'us';
}

const UV_BANDS = [
[11, 'extreme', 4],
[8, 'veryHigh', 3],
[6, 'high', 2],
[3, 'moderate', 1],
];
const AIR_BANDS = {
us: [
[301, 'hazardous', 5],
[201, 'veryUnhealthy', 4],
[151, 'unhealthy', 3],
[101, 'sensitive', 2],
],
eu: [
[100, 'extremelyPoor', 4],
[80, 'veryPoor', 3],
[60, 'poor', 2],
],
};
const band = (bands, value) => bands.find(([min]) => value >= min);

function alertsFrom(uv, aqi, scale) {
const out = [];
if (Number.isFinite(aqi)) {
const value = Math.round(aqi);
const b = band(AIR_BANDS[scale], value);
if (b) out.push({ kind: 'air', scale, value, band: b[1], level: b[2] });
}
if (Number.isFinite(uv)) {
const value = Math.round(uv);
const b = band(UV_BANDS, value);
if (b) out.push({ kind: 'uv', value, band: b[1], level: b[2] });
}
return out.sort((a, b) => b.level - a.level);
}

/* Keep the catch. An air quality outage must not blank the weather. */
async function exposure(ctx, lat, lon, scale) {
const key = scale === 'eu' ? 'european_aqi' : 'us_aqi';
const url =
'https://air-quality-api.open-meteo.com/v1/air-quality' +
`?latitude=${encodeURIComponent(lat)}&longitude=${encodeURIComponent(lon)}` +
`&current=uv_index,${key}&timezone=auto`;
try {
const r = await ctx.fetchJSON(url, { timeout: 8000 });
const cur = r.status < 400 && r.data && r.data.current;
if (!cur) return [];
return alertsFrom(cur.uv_index, cur[key], scale);
} catch {
return [];
}
}

module.exports = async function (ctx) {
const { config } = ctx;
if (ctx.endpoint === 'geocode') return await geocode(ctx);
Expand All @@ -101,8 +218,15 @@ module.exports = async function (ctx) {
if (lat == null || lon == null || lat === '' || lon === '') ctx.fail('Location not set', { kind: ctx.KIND.INVALID });
const units = config.units === 'f' ? 'f' : 'c';

const read = config.provider === 'openweather' ? openWeather : openMeteo;
const w = await read(ctx, lat, lon, units);
const ow = config.provider === 'openweather';
if (ow && !owKey(config)) ctx.fail('Enter an OpenWeather API key.', { kind: ctx.KIND.INVALID });

/* Keep both requests concurrent. In sequence they pass the API's hold time
and every poll shows the previous reading. */
const warn = config.warnings !== false && config.warnings !== 'false';
const air = warn ? exposure(ctx, lat, lon, airScale(config)) : [];
const w = await (ow ? openWeather : openMeteo)(ctx, lat, lon, units);
const alerts = await air;

const useFeels = config.feelsLike === true || config.feelsLike === 'true';
const temp = useFeels ? w.feels : w.real;
Expand All @@ -112,7 +236,9 @@ module.exports = async function (ctx) {
usedFeels: useFeels,
units: units,
code: w.code,
...(w.condition && { condition: w.condition }),
isDay: w.isDay,
city: config.city || '',
...(alerts.length && { alerts }),
};
};
35 changes: 34 additions & 1 deletion public/widgets/weather/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"units.opt.c": "°C",
"units.opt.f": "°F",
"feelsLike.label": "Gefühlte Temperatur anzeigen",
"warnings.label": "UV- und Luftwarnungen anzeigen",
"warnings.hint": "Zeigt ein Symbol, wenn UV-Strahlung oder Luftqualität Vorsicht erfordern. Luftqualitätsdaten: Copernicus CAMS über Open-Meteo.",
"href.label": "Link-URL",
"href.placeholder": "https://…",
"ui.clear": "Klar",
Expand All @@ -27,8 +29,39 @@
"ui.snow": "Schnee",
"ui.snowShowers": "Schneeschauer",
"ui.thunderstorm": "Gewitter",
"ui.mist": "Feuchter Dunst",
"ui.rimeFog": "Gefrierender Nebel",
"ui.freezingDrizzle": "Gefrierender Nieselregen",
"ui.freezingRain": "Gefrierender Regen",
"ui.sleet": "Eiskörner",
"ui.rainAndSnow": "Schneeregen",
"ui.snowGrains": "Schneegriesel",
"ui.hailstorm": "Hagelgewitter",
"ui.haze": "Dunst",
"ui.smoke": "Rauch",
"ui.dust": "Staub",
"ui.sand": "Sand",
"ui.volcanicAsh": "Vulkanasche",
"ui.squalls": "Sturmböen",
"ui.tornado": "Tornado",
"ui.summary": "{place}: {condition}, {temp}",
"ui.updated": "{summary}. Aktualisiert {time}",
"ui.currentLocation": "Aktueller Standort",
"ui.feelsLikeTemp": "{temp} (gefühlt)"
"ui.feelsLikeTemp": "{temp} (gefühlt)",
"ui.uvShort": "UV {value}",
"ui.airShort": "LQI {value}",
"ui.uvAlert": "UV-Index {value}, {level}",
"ui.airAlert": "Luftqualitätsindex {value}, {level}",
"ui.withAlert": "{summary}. {alert}",
"ui.moderate": "mittel",
"ui.high": "hoch",
"ui.veryHigh": "sehr hoch",
"ui.extreme": "extrem",
"ui.sensitive": "ungesund für empfindliche Gruppen",
"ui.unhealthy": "ungesund",
"ui.veryUnhealthy": "sehr ungesund",
"ui.hazardous": "gefährlich",
"ui.poor": "schlecht",
"ui.veryPoor": "sehr schlecht",
"ui.extremelyPoor": "extrem schlecht"
}
35 changes: 34 additions & 1 deletion public/widgets/weather/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"units.opt.c": "°C",
"units.opt.f": "°F",
"feelsLike.label": "Show feels like",
"warnings.label": "Show UV and air warnings",
"warnings.hint": "Shows an icon when UV or air quality calls for care. Air quality data: Copernicus CAMS, through Open-Meteo.",
"href.label": "Link URL",
"href.placeholder": "https://…",
"ui.clear": "Clear",
Expand All @@ -27,8 +29,39 @@
"ui.snow": "Snow",
"ui.snowShowers": "Snow Showers",
"ui.thunderstorm": "Thunderstorm",
"ui.mist": "Mist",
"ui.rimeFog": "Rime Fog",
"ui.freezingDrizzle": "Freezing Drizzle",
"ui.freezingRain": "Freezing Rain",
"ui.sleet": "Sleet",
"ui.rainAndSnow": "Rain and Snow",
"ui.snowGrains": "Snow Grains",
"ui.hailstorm": "Hailstorm",
"ui.haze": "Haze",
"ui.smoke": "Smoke",
"ui.dust": "Dust",
"ui.sand": "Sand",
"ui.volcanicAsh": "Volcanic Ash",
"ui.squalls": "Squalls",
"ui.tornado": "Tornado",
"ui.summary": "{place}: {condition}, {temp}",
"ui.updated": "{summary}. Updated {time}",
"ui.currentLocation": "Current location",
"ui.feelsLikeTemp": "{temp} (feels like)"
"ui.feelsLikeTemp": "{temp} (feels like)",
"ui.uvShort": "UV {value}",
"ui.airShort": "AQI {value}",
"ui.uvAlert": "UV index {value}, {level}",
"ui.airAlert": "Air quality index {value}, {level}",
"ui.withAlert": "{summary}. {alert}",
"ui.moderate": "moderate",
"ui.high": "high",
"ui.veryHigh": "very high",
"ui.extreme": "extreme",
"ui.sensitive": "unhealthy for sensitive groups",
"ui.unhealthy": "unhealthy",
"ui.veryUnhealthy": "very unhealthy",
"ui.hazardous": "hazardous",
"ui.poor": "poor",
"ui.veryPoor": "very poor",
"ui.extremelyPoor": "extremely poor"
}
Loading
Loading