From 91ff762c5f09d6cb290244fb6cf2a9845014b85b Mon Sep 17 00:00:00 2001 From: fraxle Date: Tue, 2 Jun 2026 15:40:05 +0100 Subject: [PATCH] 2.2.1 Fix wet, windy and storms --- assets/js/app.js | 25 ++++++- assets/js/events.js | 6 +- assets/js/events/lens-overlay.js | 18 ++++- assets/js/events/weather-checks.js | 109 ++++++++++++++++++++++++++--- 4 files changed, 145 insertions(+), 13 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index c54ee54..5c9a416 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -177,6 +177,22 @@ export function UTCIForecast() { skinType, ); + // Day's events surfaced in the "Today at a glance" box. Reuses the same + // row shape as glanceSummary items: { icon, label, value, alert }. + const hhmm = (iso) => (iso ? iso.slice(11, 16) : ''); + const eventGlanceItems = (selectedDayEvents ?? []) + .filter(ev => ev.type !== 'promo') + .map(ev => ({ + icon: ev.emoji, + label: ev.title, + value: ev.isoRange + ? (ev.isoRange[0] === ev.isoRange[1] + ? hhmm(ev.isoRange[0]) + : `${hhmm(ev.isoRange[0])} – ${hhmm(ev.isoRange[1])}`) + : (ev.nightOnly ? 'Overnight' : 'All day'), + alert: false, + })); + // ─── 4. JSX RETURN ─────────────────────────────────────────────────── // Everything below is the actual page markup, written as one big HTM // template. Search tips: @@ -902,7 +918,7 @@ export function UTCIForecast() { - ${forecast && glanceSummary && glanceSummary.length > 0 && html` + ${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html` `} diff --git a/assets/js/events.js b/assets/js/events.js index 0a9df5e..92bcda4 100644 --- a/assets/js/events.js +++ b/assets/js/events.js @@ -31,6 +31,8 @@ import { checkStargazing, checkSunset, checkHeatSpike, + checkWind, + checkWet, checkStorm, checkFrost, } from './events/weather-checks.js'; @@ -76,7 +78,7 @@ export function getCellTagEvents(events, row) { // --- BANNER PRIORITY ----------------------------------------------------- // Weather event IDs that are safety-relevant and should always appear as the // first banner slide, ahead of cosmic events and lower-priority weather items. -const PRIORITY_WEATHER_IDS = new Set(['heat-spike', 'frost', 'storm']); +const PRIORITY_WEATHER_IDS = new Set(['heat-spike', 'frost', 'storm', 'wind', 'wet']); // --- MAIN EXPORT --------------------------------------------------------- // Returns ALL active events for the given rows/date as an array. @@ -106,6 +108,8 @@ export function getActiveEvents(rows, location) { const checks = [ checkHeatSpike(rows), checkStorm(rows), + checkWind(rows), + checkWet(rows), checkFrost(rows), checkSunset(rows, location), checkStargazing(rows), diff --git a/assets/js/events/lens-overlay.js b/assets/js/events/lens-overlay.js index c535f09..69529f2 100644 --- a/assets/js/events/lens-overlay.js +++ b/assets/js/events/lens-overlay.js @@ -81,13 +81,29 @@ export function getLensOverlaySVG(event, cx, cy, lensR) { + ''; } - if (id === 'storm') { + if (id === 'wet') { const sl = [-55,-30,-5,20,45,65].map(function(x){ return ''; }).join(''); return '' + sl + ''; } + if (id === 'wind') { + const wl = [-30,5,40].map(function(y){ + return ''; + }).join(''); + return '' + wl + ''; + } + + if (id === 'storm') { + // Driving rain streaks plus a forked lightning bolt. + const sl = [-55,-30,-5,20,45,65].map(function(x){ + return ''; + }).join(''); + const bolt = ''; + return '' + sl + bolt + ''; + } + if (id === 'frost') { const fc = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(function(p){ var dx=p[0], dy=p[1]; diff --git a/assets/js/events/weather-checks.js b/assets/js/events/weather-checks.js index 61da782..ccfd74b 100644 --- a/assets/js/events/weather-checks.js +++ b/assets/js/events/weather-checks.js @@ -85,6 +85,13 @@ export function checkHeatSpike(rows) { const maxTemp = Math.max(...rows.map(r => r.Ta).filter(isFinite)); if (maxTemp < 30) return null; const severity = maxTemp >= 36 ? 'extreme' : maxTemp >= 33 ? 'severe' : 'notable'; + + // Only flag the hours that are actually at/above the heat threshold. + const affected = rows.filter(r => isFinite(r.Ta) && r.Ta >= 30); + const isoRange = affected.length + ? [affected[0].iso, affected[affected.length - 1].iso] + : undefined; + const msgs = { notable: `Temperatures reaching ${maxTemp.toFixed(1)}°C — above seasonal norms. Stay hydrated and avoid prolonged sun exposure.`, severe: `Heat warning: ${maxTemp.toFixed(1)}°C expected today. Risk of heat exhaustion for vulnerable people — keep cool and hydrated.`, @@ -99,31 +106,112 @@ export function checkHeatSpike(rows) { textColor: '#ffd0b0', type: 'weather', nightOnly: false, + isoRange, // only show cell icon during the hot hours }; } -export function checkStorm(rows) { - const maxGust = Math.max(...rows.map(r => r.gust ?? r.va ?? 0).filter(isFinite)); - const maxPrecip = Math.max(...rows.map(r => r.precip ?? 0).filter(isFinite)); - if (maxGust < 15 && maxPrecip < 5) return null; - const isStorm = maxGust >= 20 || maxPrecip >= 10; +// Wind and rain are detected separately so each icon lands only on the hours +// that genuinely have that condition. They are mutually exclusive per hour: +// windy + dry → wind icon (💨) +// wet + calm → rain icon (🌧️) +// windy + wet → storm icon (🌩️), and the wind/rain icons are suppressed +// for that hour so a single hour never shows two icons. +const GUST_BLUSTERY = 25 / 2.237; // m/s (= 25 mph) - matches the table's gust highlight +const GUST_STORM = 20; // m/s ≈ 45 mph - storm-force +const PRECIP_WET = 5; // mm/h - meaningful rain +const PRECIP_HEAVY = 10; // mm/h - heavy rain + +const gustOf = r => r.gust ?? r.va ?? 0; +const precipOf = r => r.precip ?? 0; +const isWindyHour = r => { const g = gustOf(r); return isFinite(g) && g >= GUST_BLUSTERY; }; +const isWetHour = r => { const p = precipOf(r); return isFinite(p) && p >= PRECIP_WET; }; +const isStormHour = r => isWindyHour(r) && isWetHour(r); + +export function checkWind(rows) { + // Windy hours that are NOT also wet (those are storms instead). + const affected = rows.filter(r => isWindyHour(r) && !isStormHour(r)); + if (affected.length === 0) return null; + const isoRange = [affected[0].iso, affected[affected.length - 1].iso]; + + const maxGust = Math.max(...affected.map(gustOf).filter(isFinite)); + const mph = Math.round(maxGust * 2.237); + const isStorm = maxGust >= GUST_STORM; + return { - id: 'storm', - emoji: '🌩️', - title: isStorm ? 'Storm Conditions Forecast' : 'Blustery & Wet Today', + id: 'wind', + emoji: '💨', + title: isStorm ? 'Storm-Force Winds' : 'Blustery Conditions', message: isStorm - ? `Storm-level conditions expected — gusts to ${Math.round(maxGust * 2.237)} mph with heavy precipitation. Take care outdoors.` - : `Unsettled day ahead — windy with gusts to ${Math.round(maxGust * 2.237)} mph and ${maxPrecip.toFixed(1)} mm/h rain at peak.`, + ? `Storm-force gusts to ${mph} mph expected — secure loose objects and take care outdoors.` + : `Windy spell — gusts to ${mph} mph at peak. Hold onto hats and secure loose items outdoors.`, color: '#1a2030', textColor: '#c0d8f0', type: 'weather', nightOnly: false, + isoRange, // only show cell icon during the windy hours + }; +} + +export function checkWet(rows) { + // Wet hours that are NOT also windy (those are storms instead). + const affected = rows.filter(r => isWetHour(r) && !isStormHour(r)); + if (affected.length === 0) return null; + const isoRange = [affected[0].iso, affected[affected.length - 1].iso]; + + const maxPrecip = Math.max(...affected.map(precipOf).filter(isFinite)); + const isHeavy = maxPrecip >= PRECIP_HEAVY; + + return { + id: 'wet', + emoji: '🌧️', + title: 'Heavy Rain', + message: isHeavy + ? `Heavy rain expected — up to ${maxPrecip.toFixed(1)} mm/h at peak. Roads may flood; allow extra travel time.` + : `Wet spell — up to ${maxPrecip.toFixed(1)} mm/h rain at peak. Keep a brolly handy.`, + color: '#15212e', + textColor: '#bcd6ee', + type: 'weather', + nightOnly: false, + isoRange, // only show cell icon during the wet hours + }; +} + +export function checkStorm(rows) { + // Storm hours = windy AND wet at the same time. + const affected = rows.filter(isStormHour); + if (affected.length === 0) return null; + const isoRange = [affected[0].iso, affected[affected.length - 1].iso]; + + const maxGust = Math.max(...affected.map(gustOf).filter(isFinite)); + const maxPrecip = Math.max(...affected.map(precipOf).filter(isFinite)); + const mph = Math.round(maxGust * 2.237); + const isSevere = maxGust >= GUST_STORM || maxPrecip >= PRECIP_HEAVY; + + return { + id: 'storm', + emoji: '🌩️', + title: isSevere ? 'Severe Storm' : 'Storm Conditions', + message: isSevere + ? `Severe storm — driving rain to ${maxPrecip.toFixed(1)} mm/h and gusts to ${mph} mph. Avoid travel if you can.` + : `Stormy spell — wind and rain together, gusts to ${mph} mph with ${maxPrecip.toFixed(1)} mm/h rain. Take care outdoors.`, + color: '#141a26', + textColor: '#c0d0e8', + type: 'weather', + nightOnly: false, + isoRange, // only show cell icon during the stormy hours }; } export function checkFrost(rows) { const minTemp = Math.min(...rows.map(r => r.Ta).filter(isFinite)); if (minTemp > 2) return null; + + // Only flag the hours that are actually at/below the frost threshold. + const affected = rows.filter(r => isFinite(r.Ta) && r.Ta <= 2); + const isoRange = affected.length + ? [affected[0].iso, affected[affected.length - 1].iso] + : undefined; + return { id: 'frost', emoji: '❄️', @@ -135,5 +223,6 @@ export function checkFrost(rows) { textColor: '#c8e8ff', type: 'weather', nightOnly: false, + isoRange, // only show cell icon during the frosty hours }; }