diff --git a/public/api/widget-config/w-weather-uv b/public/api/widget-config/w-weather-uv new file mode 100644 index 0000000..7ff5350 --- /dev/null +++ b/public/api/widget-config/w-weather-uv @@ -0,0 +1 @@ +{"widgetSize":"small","widgetConfig":{"city":"Seville, Andalusia, Spain","lat":37.3891,"lon":-5.9845,"units":"c"}} diff --git a/public/api/widget-data/w-weather-uv b/public/api/widget-data/w-weather-uv new file mode 100644 index 0000000..70cc1f2 --- /dev/null +++ b/public/api/widget-data/w-weather-uv @@ -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}]} diff --git a/public/js/icon-set.js b/public/js/icon-set.js index 12c9d1a..0c7b97b 100644 --- a/public/js/icon-set.js +++ b/public/js/icon-set.js @@ -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')] }, diff --git a/public/js/utils.js b/public/js/utils.js index 404721d..3257a3b 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -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 {} diff --git a/public/widgets/weather/data.js b/public/widgets/weather/data.js index 94ad87b..59c1925 100644 --- a/public/widgets/weather/data.js +++ b/public/widgets/weather/data.js @@ -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(); @@ -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 }; } @@ -47,6 +49,9 @@ 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; @@ -54,18 +59,21 @@ function wmoFromOpenWeather(id) { 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)}` + @@ -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)}` + + `¤t=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); @@ -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; @@ -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 }), }; }; diff --git a/public/widgets/weather/i18n/de.json b/public/widgets/weather/i18n/de.json index d7976df..27c50cb 100644 --- a/public/widgets/weather/i18n/de.json +++ b/public/widgets/weather/i18n/de.json @@ -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", @@ -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" } diff --git a/public/widgets/weather/i18n/en.json b/public/widgets/weather/i18n/en.json index 96a8bed..9a2783f 100644 --- a/public/widgets/weather/i18n/en.json +++ b/public/widgets/weather/i18n/en.json @@ -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", @@ -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" } diff --git a/public/widgets/weather/i18n/es.json b/public/widgets/weather/i18n/es.json index b40e483..04d63e0 100644 --- a/public/widgets/weather/i18n/es.json +++ b/public/widgets/weather/i18n/es.json @@ -14,6 +14,8 @@ "units.opt.c": "°C", "units.opt.f": "°F", "feelsLike.label": "Mostrar sensación térmica", + "warnings.label": "Mostrar avisos de UV y calidad del aire", + "warnings.hint": "Muestra un icono cuando la radiación UV o la calidad del aire requieren precaución. Datos de calidad del aire: Copernicus CAMS, a través de Open-Meteo.", "href.label": "URL del enlace", "href.placeholder": "https://…", "ui.clear": "Despejado", @@ -27,8 +29,39 @@ "ui.snow": "Nieve", "ui.snowShowers": "Chubascos de nieve", "ui.thunderstorm": "Tormenta", + "ui.mist": "Neblina", + "ui.rimeFog": "Niebla helada", + "ui.freezingDrizzle": "Llovizna engelante", + "ui.freezingRain": "Lluvia engelante", + "ui.sleet": "Hielo granulado", + "ui.rainAndSnow": "Aguanieve", + "ui.snowGrains": "Cinarra", + "ui.hailstorm": "Granizada", + "ui.haze": "Bruma", + "ui.smoke": "Humo", + "ui.dust": "Polvo", + "ui.sand": "Arena", + "ui.volcanicAsh": "Ceniza volcánica", + "ui.squalls": "Turbonadas", + "ui.tornado": "Tornado", "ui.summary": "{place}: {condition}, {temp}", "ui.updated": "{summary}. Actualizado {time}", "ui.currentLocation": "Ubicación actual", - "ui.feelsLikeTemp": "{temp} (sensación térmica)" + "ui.feelsLikeTemp": "{temp} (sensación térmica)", + "ui.uvShort": "UV {value}", + "ui.airShort": "ICA {value}", + "ui.uvAlert": "Índice UV {value}, {level}", + "ui.airAlert": "Índice de calidad del aire {value}, {level}", + "ui.withAlert": "{summary}. {alert}", + "ui.moderate": "moderado", + "ui.high": "alto", + "ui.veryHigh": "muy alto", + "ui.extreme": "extremo", + "ui.sensitive": "dañino para grupos sensibles", + "ui.unhealthy": "dañino para la salud", + "ui.veryUnhealthy": "muy dañino para la salud", + "ui.hazardous": "peligroso", + "ui.poor": "desfavorable", + "ui.veryPoor": "muy desfavorable", + "ui.extremelyPoor": "extremadamente desfavorable" } diff --git a/public/widgets/weather/i18n/fa.json b/public/widgets/weather/i18n/fa.json index 3ecea89..ba5a5dd 100644 --- a/public/widgets/weather/i18n/fa.json +++ b/public/widgets/weather/i18n/fa.json @@ -14,6 +14,8 @@ "units.opt.c": "°C", "units.opt.f": "°F", "feelsLike.label": "نمایش دمای محسوس", + "warnings.label": "نمایش هشدار فرابنفش و کیفیت هوا", + "warnings.hint": "وقتی پرتو فرابنفش یا کیفیت هوا نیاز به احتیاط دارد، نمادی نشان می‌دهد. داده‌های کیفیت هوا: Copernicus CAMS از طریق Open-Meteo.", "href.label": "آدرس پیوند", "href.placeholder": "https://…", "ui.clear": "صاف", @@ -27,8 +29,39 @@ "ui.snow": "برف", "ui.snowShowers": "رگبار برف", "ui.thunderstorm": "رعدوبرق", + "ui.mist": "مه رقیق", + "ui.rimeFog": "مه یخ‌زده", + "ui.freezingDrizzle": "نم‌نم باران یخ‌زن", + "ui.freezingRain": "باران یخ‌زن", + "ui.sleet": "دانه‌های یخ", + "ui.rainAndSnow": "برف و باران", + "ui.snowGrains": "برف دانه‌ریز", + "ui.hailstorm": "تگرگ", + "ui.haze": "غبار رقیق", + "ui.smoke": "دود", + "ui.dust": "گردوغبار", + "ui.sand": "شن", + "ui.volcanicAsh": "خاکستر آتشفشان", + "ui.squalls": "تندباد", + "ui.tornado": "گردباد", "ui.summary": "{place}: {condition}، {temp}", "ui.updated": "{summary}. {time} به‌روزرسانی شد", "ui.currentLocation": "موقعیت فعلی", - "ui.feelsLikeTemp": "{temp} (دمای ظاهری)" + "ui.feelsLikeTemp": "{temp} (دمای ظاهری)", + "ui.uvShort": "UV {value}", + "ui.airShort": "AQI {value}", + "ui.uvAlert": "شاخص فرابنفش {value}، {level}", + "ui.airAlert": "شاخص کیفیت هوا {value}، {level}", + "ui.withAlert": "{summary}. {alert}", + "ui.moderate": "متوسط", + "ui.high": "زیاد", + "ui.veryHigh": "بسیار زیاد", + "ui.extreme": "شدید", + "ui.sensitive": "ناسالم برای گروه‌های حساس", + "ui.unhealthy": "ناسالم", + "ui.veryUnhealthy": "بسیار ناسالم", + "ui.hazardous": "خطرناک", + "ui.poor": "بد", + "ui.veryPoor": "بسیار بد", + "ui.extremelyPoor": "به‌شدت بد" } diff --git a/public/widgets/weather/i18n/fr.json b/public/widgets/weather/i18n/fr.json index cd842b9..491db19 100644 --- a/public/widgets/weather/i18n/fr.json +++ b/public/widgets/weather/i18n/fr.json @@ -14,6 +14,8 @@ "units.opt.c": "°C", "units.opt.f": "°F", "feelsLike.label": "Afficher la température ressentie", + "warnings.label": "Afficher les alertes UV et qualité de l’air", + "warnings.hint": "Affiche une icône quand les UV ou la qualité de l’air demandent des précautions. Données de qualité de l’air : Copernicus CAMS, via Open-Meteo.", "href.label": "URL du lien", "href.placeholder": "https://…", "ui.clear": "Dégagé", @@ -27,8 +29,39 @@ "ui.snow": "Neige", "ui.snowShowers": "Averses de neige", "ui.thunderstorm": "Orage", + "ui.mist": "Brume", + "ui.rimeFog": "Brouillard givrant", + "ui.freezingDrizzle": "Bruine verglaçante", + "ui.freezingRain": "Pluie verglaçante", + "ui.sleet": "Granules de glace", + "ui.rainAndSnow": "Pluie et neige", + "ui.snowGrains": "Neige en grains", + "ui.hailstorm": "Orage de grêle", + "ui.haze": "Brume sèche", + "ui.smoke": "Fumée", + "ui.dust": "Poussière", + "ui.sand": "Sable", + "ui.volcanicAsh": "Cendres volcaniques", + "ui.squalls": "Rafales", + "ui.tornado": "Tornade", "ui.summary": "{place} : {condition}, {temp}", "ui.updated": "{summary}. Mis à jour {time}", "ui.currentLocation": "Position actuelle", - "ui.feelsLikeTemp": "{temp} (ressenti)" + "ui.feelsLikeTemp": "{temp} (ressenti)", + "ui.uvShort": "UV {value}", + "ui.airShort": "IQA {value}", + "ui.uvAlert": "Indice UV {value}, {level}", + "ui.airAlert": "Indice de qualité de l’air {value}, {level}", + "ui.withAlert": "{summary}. {alert}", + "ui.moderate": "modéré", + "ui.high": "élevé", + "ui.veryHigh": "très élevé", + "ui.extreme": "extrême", + "ui.sensitive": "mauvais pour les groupes sensibles", + "ui.unhealthy": "mauvais pour la santé", + "ui.veryUnhealthy": "très mauvais pour la santé", + "ui.hazardous": "dangereux", + "ui.poor": "mauvais", + "ui.veryPoor": "très mauvais", + "ui.extremelyPoor": "extrêmement mauvais" } diff --git a/public/widgets/weather/i18n/zh-Hans.json b/public/widgets/weather/i18n/zh-Hans.json index 5972f42..e71a380 100644 --- a/public/widgets/weather/i18n/zh-Hans.json +++ b/public/widgets/weather/i18n/zh-Hans.json @@ -14,6 +14,8 @@ "units.opt.c": "°C", "units.opt.f": "°F", "feelsLike.label": "显示体感温度", + "warnings.label": "显示紫外线和空气质量提醒", + "warnings.hint": "紫外线或空气质量需要注意时显示图标。空气质量数据:Copernicus CAMS,经由 Open-Meteo。", "href.label": "链接地址", "href.placeholder": "https://…", "ui.clear": "晴", @@ -21,14 +23,45 @@ "ui.partlyCloudy": "局部多云", "ui.cloudy": "阴", "ui.fog": "雾", - "ui.drizzle": "小雨", + "ui.drizzle": "毛毛雨", "ui.rain": "雨", "ui.showers": "阵雨", "ui.snow": "雪", "ui.snowShowers": "阵雪", "ui.thunderstorm": "雷雨", + "ui.mist": "轻雾", + "ui.rimeFog": "冻雾", + "ui.freezingDrizzle": "冻毛毛雨", + "ui.freezingRain": "冻雨", + "ui.sleet": "冰粒", + "ui.rainAndSnow": "雨夹雪", + "ui.snowGrains": "米雪", + "ui.hailstorm": "冰雹", + "ui.haze": "霾", + "ui.smoke": "烟雾", + "ui.dust": "浮尘", + "ui.sand": "扬沙", + "ui.volcanicAsh": "火山灰", + "ui.squalls": "飑", + "ui.tornado": "龙卷风", "ui.summary": "{place}:{condition},{temp}", "ui.updated": "{summary}。{time}更新", "ui.currentLocation": "当前位置", - "ui.feelsLikeTemp": "{temp}(体感)" + "ui.feelsLikeTemp": "{temp}(体感)", + "ui.uvShort": "UV {value}", + "ui.airShort": "AQI {value}", + "ui.uvAlert": "紫外线指数 {value},{level}", + "ui.airAlert": "空气质量指数 {value},{level}", + "ui.withAlert": "{summary}。{alert}", + "ui.moderate": "中等", + "ui.high": "强", + "ui.veryHigh": "很强", + "ui.extreme": "极强", + "ui.sensitive": "对敏感人群不健康", + "ui.unhealthy": "不健康", + "ui.veryUnhealthy": "非常不健康", + "ui.hazardous": "危险", + "ui.poor": "差", + "ui.veryPoor": "很差", + "ui.extremelyPoor": "极差" } diff --git a/public/widgets/weather/index.html b/public/widgets/weather/index.html index 4a39ee7..208332b 100644 --- a/public/widgets/weather/index.html +++ b/public/widgets/weather/index.html @@ -14,26 +14,49 @@ .widget{width:100%;height:100%;position:relative;border-radius:inherit} .widget.clickable{cursor:pointer} .widget.clickable:focus-visible{outline:2px solid #0091FF;outline-offset:-3px} + .widget[tabindex]:focus-visible{outline:2px solid #0091FF;outline-offset:-3px} .ico{position:absolute;top:6px;left:50%;transform:translateX(-50%);width:62%;max-width:110px;aspect-ratio:1/1;display:block;overflow:visible} .meta{position:absolute;left:18px;right:18px;bottom:14px;display:flex;flex-direction:column;gap:2px} .cond{font-size:15px;font-weight:600;color:#2C2C2E;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .temp{font-size:42px;font-weight:700;line-height:1;letter-spacing:-0.01em;color:#f0852a} .msg{margin-top:3px} /* Only on elements without a transform attribute. A transform-origin on one moves it. */ - .ico .wx-drop,.ico .wx-flake{transform-box:fill-box;transform-origin:center} + .ico .wx-drop,.ico .wx-flake,.ico .wx-pellet{transform-box:fill-box;transform-origin:center} @keyframes wxdrop{0%{transform:translate(0,0);opacity:0}22%{opacity:1}80%{opacity:1}100%{transform:translate(-7.5px,15.2px);opacity:0}} @keyframes wxflake{0%{transform:translate(0,-2px) rotate(0);opacity:0}18%{opacity:1}50%{transform:translate(2px,6px) rotate(45deg)}85%{opacity:1}100%{transform:translate(-1px,15px) rotate(90deg);opacity:0}} + @keyframes wxpellet{0%{transform:translate(0,-2px);opacity:0}18%{opacity:1}85%{opacity:1}100%{transform:translate(-3px,17px);opacity:0}} + @keyframes wxslide{to{transform:translateX(var(--d))}} + @keyframes wxspin{0%{stroke-dashoffset:0}100%{stroke-dashoffset:-100}} @keyframes wxflash{0%,100%{opacity:0}1.5%{opacity:.5}4%{opacity:.08}6.5%{opacity:.35}12%{opacity:0}} @keyframes wxbolt{0%{opacity:1}5%{opacity:.7}7%{opacity:1}20%,100%{opacity:.6}} .wx-drop{animation:wxdrop 1.1s cubic-bezier(.45,0,.9,.6) infinite} .wx-flake{animation:wxflake 4.2s linear infinite} + .wx-pellet{animation:wxpellet 1.5s cubic-bezier(.4,0,.9,.7) infinite} + .wx-flow{animation:wxslide 2.4s linear infinite} + .wx-spin{stroke-dasharray:28 72;animation:wxspin 1.2s linear infinite} .wx-flash{animation:wxflash 6.5s linear infinite} .wx-bolt{animation:wxbolt 6.5s linear infinite} .wx-draw{stroke-dasharray:1} .ico.off *{animation-play-state:paused} - @media(prefers-reduced-motion:reduce){.ico *{animation:none}.ico .wx-drop{transform:translate(-3.75px,7.6px)}.ico .wx-flake{transform:translate(1px,6px)}} + @media(prefers-reduced-motion:reduce){.ico *{animation:none}.ico .wx-drop{transform:translate(-3.75px,7.6px)}.ico .wx-flake{transform:translate(1px,6px)}.ico .wx-pellet{transform:translate(-1.5px,8px)}.ico .wx-flow{transform:translateX(calc(var(--d) * .4))}} .ico .ph{fill:rgba(235,235,245,0.16)} html[data-theme="light"] .ico .ph{fill:rgba(60,60,67,0.12)} + .aq{position:absolute;inset-block-start:4px;inset-inline-end:4px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:#2C2C2E} + .aq[hidden]{display:none} + .aq-hit{display:flex} + .aq-cap{position:absolute;inset-block-start:2px;inset-inline-end:2px;display:flex;align-items:center;gap:5px;padding-block:5px;padding-inline:10px 5px;border-radius:999px;font-size:13px;font-weight:600;line-height:18px;white-space:nowrap;color:#2C2C2E;background:rgba(255,255,255,0.82);box-shadow:0 1px 6px rgba(0,0,0,0.12);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);opacity:0;visibility:hidden;pointer-events:none;transition:opacity .2s,visibility .2s} + .night .aq,.night .aq-cap{color:#FFFFFF} + .night .aq-cap{background:rgba(58,58,60,0.82);box-shadow:0 1px 6px rgba(0,0,0,0.4)} + @media(hover:hover){.widget:hover .aq-cap{opacity:1;visibility:visible}} + .widget:focus-visible .aq-cap,.widget.peek .aq-cap{opacity:1;visibility:visible} + .widget.hush .aq-cap{opacity:0;visibility:hidden} + @media(prefers-reduced-motion:reduce){.aq-cap{transition:none}} + .sy-sprite{position:absolute;width:0;height:0;overflow:hidden} + .sy-icon{flex-shrink:0} + .sy-i{fill:currentColor} + .sy-a{fill:var(--icon-accent,currentColor)} + .sy-is{stroke:currentColor} + .sy-as{stroke:var(--icon-accent,currentColor)} .sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} @@ -45,10 +68,15 @@
+