// ------------------------------------------------------------------------ // weather-checks.js - Weather-derived event detectors. // // Computed in real-time from the forecast data. // Each checker function receives (rows [, location]) and returns an event // object or null. rows = today's hourlyRows array. // location = { lat, lon, name, country }. // // To add a new weather event: write a checkXxx(rows, location) function // below, export it, and add it to the checks[] array inside // getActiveEvents() (in events.js). // // Priority (safety) events also carry a `tint` — an [r,g,b] triple taken // from the same WEATHER TINT PALETTE the day tabs colour themselves from // (utils.js), so a heat banner reads the same orange as a hot day tab, a // rain banner the same blue, and so on. app.js turns it into the banner // background; events without a tint keep the default parchment banner. // ------------------------------------------------------------------------ import { utciCategory, hexToRgb, rainTint, cloudTint } from '../utils.js'; export function checkStargazing(rows) { // Great stargazing: mostly clear night hours with low cloud. // Split night into contiguous runs (pre-dawn vs post-dusk) and evaluate // each independently so a cloudy evening doesn't suppress a clear morning // (or vice versa), and so the icon is pinned to only the clear hours. const nightRows = rows.filter(r => r.elev < -5); if (nightRows.length < 3) return null; // Build contiguous runs — a gap > 2 h means we've crossed daytime. const runs = []; let cur = [nightRows[0]]; for (let i = 1; i < nightRows.length; i++) { const dtDiff = (new Date(nightRows[i].iso) - new Date(nightRows[i - 1].iso)) / 3600000; if (dtDiff <= 2) { cur.push(nightRows[i]); } else { runs.push(cur); cur = [nightRows[i]]; } } runs.push(cur); // Evaluate each run; among those passing ≤30% avg cloud prefer the evening // run (actionable for tonight) then fall back to whichever is clearest. let bestRun = null, bestAvg = Infinity; for (const run of runs) { if (run.length < 2) continue; const avg = run.reduce((s, r) => s + r.cc, 0) / run.length; if (avg > 30) continue; const isEvening = parseInt(run[0].iso.slice(11, 13), 10) >= 12; if (!bestRun || (isEvening && parseInt(bestRun[0].iso.slice(11, 13), 10) < 12) || avg < bestAvg) { bestRun = run; bestAvg = avg; } } if (!bestRun) return null; const isPreDawn = parseInt(bestRun[0].iso.slice(11, 13), 10) < 12; return { id: 'stargazing', emoji: '⭐', title: isPreDawn ? 'Great Stargazing Overnight' : 'Great Stargazing Tonight', message: `Clear skies expected for ${bestRun.length} hours overnight with average ${Math.round(bestAvg)}% cloud — ideal conditions for stargazing.`, color: '#080e1a', textColor: '#c8dcff', type: 'weather', nightOnly: true, isoRange: [bestRun[0].iso, bestRun[bestRun.length - 1].iso], }; } export function checkSunset(rows, location) { // Find the actual sunset window: the LAST contiguous run of rows where // solar elevation is in the golden/civil-twilight band (-3- to 8-). // This distinguishes sunset from sunrise (which is the first such run). const twilightIndices = rows .map((r, i) => ({ r, i })) .filter(({ r }) => r.elev > -3 && r.elev < 8); if (twilightIndices.length === 0) return null; // Split into runs separated by gaps (midday gap separates sunrise from sunset) const runs = []; let run = [twilightIndices[0]]; for (let k = 1; k < twilightIndices.length; k++) { if (twilightIndices[k].i === twilightIndices[k - 1].i + 1) { run.push(twilightIndices[k]); } else { runs.push(run); run = [twilightIndices[k]]; } } runs.push(run); // Use the LAST run (sunset). If only one run exists it's either sunrise-only // or a single dusk window - use it but we'll label generically. const sunsetRun = runs[runs.length - 1]; const sunsetRows = sunsetRun.map(({ r }) => r); const isSunrise = runs.length === 1 && sunsetRows[0].elev < sunsetRows[sunsetRows.length - 1].elev; // If sun is rising through the band this is a sunrise window, not sunset - skip. if (isSunrise) return null; const avgCloud = sunsetRows.reduce((s, r) => s + r.cc, 0) / sunsetRows.length; const avgLowCloud = sunsetRows.reduce((s, r) => s + (r.ccLow || 0), 0) / sunsetRows.length; if (avgLowCloud > 25) return null; if (avgCloud < 5 || avgCloud > 75) return null; // Store the ISO time range so getCellTagEvents can limit the icon to those hours const firstISO = sunsetRun[0].r.iso; const lastISO = sunsetRun[sunsetRun.length - 1].r.iso; return { id: 'perfect-sunset', emoji: '🌅', title: 'Spectacular Sunset Conditions', message: `Low cloud is clear near the horizon but high cloud will scatter the light — conditions look ideal for a vivid sunset near ${location.name}.`, color: '#3a1a00', textColor: '#ffe0b0', type: 'weather', nightOnly: false, isoRange: [firstISO, lastISO], // only show cell icon during these hours }; } // Heat stress is what a body actually experiences, so this reads SunSoak // (utciAdj — UTCI felt temperature with the precipitation penalty), not shade // air temperature: standing in full sun at 24°C air is a real heat risk that // an air-temp check misses entirely. Because the input is now on the UTCI // scale, the thresholds are simply the UTCI_BANDS boundaries (utils.js): // 27°C = Caution, 32°C = Extreme, 41°C = Danger. That keeps the banner's // severity in lockstep with the band colour shown in the table and day tabs. const HEAT_CAUTION = 27, HEAT_EXTREME = 32, HEAT_DANGER = 41; const feltOf = r => r.utciAdj ?? r.utci ?? r.Ta; export function checkHeatSpike(rows) { const maxTemp = Math.max(...rows.map(feltOf).filter(isFinite)); if (maxTemp < HEAT_CAUTION) return null; const severity = maxTemp >= HEAT_DANGER ? 'danger' : maxTemp >= HEAT_EXTREME ? 'extreme-caution' : 'caution'; // Only flag the hours that are actually at/above the heat threshold. const affected = rows.filter(r => { const t = feltOf(r); return isFinite(t) && t >= HEAT_CAUTION; }); const isoRange = affected.length ? [affected[0].iso, affected[affected.length - 1].iso] : undefined; const msgs = { 'caution': `Feeling like ${maxTemp.toFixed(1)}°C in the sun. Avoid strenuous activity outdoors, drink plenty of fluids, and wear light clothing.`, 'extreme-caution': `Heat warning: feeling like ${maxTemp.toFixed(1)}°C in the sun today. Take frequent breaks indoors in the air conditioning, avoid strenuous activity, drink plenty of fluids, and wear light clothing.`, 'danger': `Extreme heat alert: feeling like ${maxTemp.toFixed(1)}°C in the sun. Limit time spent outdoors and stay indoors with the air conditioning on as much as possible. Avoid strenuous activity, drink plenty of fluids, and wear light clothing.`, }; return { id: 'heat-spike', emoji: '🔥', title: severity === 'danger' ? 'Extreme Heat Danger Alert' : severity === 'extreme-caution' ? 'Extreme Heat Warning' : 'Heat Caution Today', message: msgs[severity], color: severity === 'danger' ? '#3a0000' : severity === 'extreme-caution' ? '#4a1000' : '#5a2000', textColor: '#ffd0b0', // Same heat hue a day tab would use for this temperature. tint: hexToRgb(utciCategory(maxTemp).bg), type: 'weather', nightOnly: false, isoRange, // only show cell icon during the hot hours }; } // 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 - Strong Breeze (Force 6) const GUST_STORM = 39 / 2.237; // m/s = 39 mph - Gale (Force 8) - storm combo trigger const PRECIP_MODERATE = 2.5; // mm/h - moderate rain threshold const PRECIP_HEAVY = 7.6; // mm/h - heavy rain (>0.3 in/hr) const PRECIP_TORRENTIAL = 20; // mm/h - torrential / very heavy // Wind category lookup from gust speed in m/s. // Uses simplified 6-tier scale matching common forecast language. function windCategory(ms) { const mph = ms * 2.237; if (mph >= 96) return { tier: 6, name: 'Violent Storm', advisory: 'Wind Advisory' }; if (mph >= 74) return { tier: 5, name: 'Storm Force', advisory: 'Storm Warning' }; if (mph >= 55) return { tier: 4, name: 'Severe Gale', advisory: 'Gale Warning' }; if (mph >= 39) return { tier: 3, name: 'Gale', advisory: 'Gale Warning' }; if (mph >= 32) return { tier: 2, name: 'Near Gale', advisory: 'Wind Advisory' }; return { tier: 1, name: 'Strong Breeze', advisory: null }; } const WIND_DESC = { 1: 'Large branches in motion; umbrellas used with difficulty.', 2: 'Whole trees in motion; inconvenience felt when walking against the wind.', 3: 'Twigs break off trees; progress on foot generally impeded.', 4: 'Slight structural damage possible — chimney pots and slates at risk.', 5: 'Trees uprooted; considerable structural damage expected.', 6: 'Very rarely experienced inland; widespread damage — avoid all outdoor activity.', }; // Rain intensity lookup from mm/h function rainCategory(mmh) { if (mmh >= PRECIP_TORRENTIAL) return { name: 'Torrential Rain', desc: 'extreme rainfall' }; if (mmh >= PRECIP_HEAVY) return { name: 'Heavy Rain', desc: 'heavy rainfall' }; if (mmh >= PRECIP_MODERATE) return { name: 'Moderate Rain', desc: 'moderate rainfall' }; return { name: 'Rain', desc: 'light rain' }; } // Combined wind+rain event naming function stormTitle(windTier, rainMmh) { const isHeavyRain = rainMmh >= PRECIP_HEAVY; if (windTier >= 5) return 'Severe Storm'; if (windTier >= 4) return isHeavyRain ? 'Windstorm with Heavy Rain' : 'Windstorm with Rain'; if (windTier >= 3) return isHeavyRain ? 'Gales with Heavy Rain' : 'Gales with Rain'; if (windTier >= 2) return isHeavyRain ? 'Blustery Heavy Rain' : 'Blustery Rain'; return isHeavyRain ? 'Blustery Showers' : 'Blustery Rain'; } // Banner colour scales with wind severity function windBannerColors(tier) { if (tier >= 5) return { color: '#0c1020', textColor: '#c0d8ff' }; if (tier >= 4) return { color: '#101828', textColor: '#c0d4f0' }; if (tier >= 2) return { color: '#141e2e', textColor: '#c0d0e8' }; return { color: '#1a2030', textColor: '#c0d8f0' }; } 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_MODERATE; }; 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 { tier, name, advisory } = windCategory(maxGust); const desc = WIND_DESC[tier] || ''; const { color, textColor } = windBannerColors(tier); const title = advisory ? `${advisory} — ${name}` : name; return { id: 'wind', emoji: tier >= 5 ? '🌪️' : '💨', title, message: `Gusts to ${mph} mph. ${desc}`, color, textColor, // Dry but windy — the day tabs' cloud-grey ramp, darkened by wind tier. tint: cloudTint(30 + tier * 12), type: 'weather', nightOnly: false, isoRange, }; } 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 { name: rainName } = rainCategory(maxPrecip); const isTorrential = maxPrecip >= PRECIP_TORRENTIAL; const isHeavy = maxPrecip >= PRECIP_HEAVY; return { id: 'wet', emoji: '🌧️', title: rainName, message: isTorrential ? `Torrential rain — up to ${maxPrecip.toFixed(1)} mm/h at peak. Flash flooding possible; avoid travel if you can.` : isHeavy ? `Heavy rain — up to ${maxPrecip.toFixed(1)} mm/h at peak. Roads may flood; allow extra travel time.` : `Moderate rain expected — up to ${maxPrecip.toFixed(1)} mm/h at peak. Keep a brolly handy.`, color: '#15212e', textColor: '#bcd6ee', // Rain wins over cloud in the day-tab priority order, so use the blue ramp. tint: rainTint(maxPrecip), type: 'weather', nightOnly: false, isoRange, }; } 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 { tier } = windCategory(maxGust); const { color, textColor } = windBannerColors(tier); const windDesc = WIND_DESC[tier] || ''; const isTorrential = maxPrecip >= PRECIP_TORRENTIAL; const isHeavy = maxPrecip >= PRECIP_HEAVY; const rainLabel = isTorrential ? 'torrential rain' : isHeavy ? 'heavy rain' : 'rain'; return { id: 'storm', emoji: '🌩️', title: stormTitle(tier, maxPrecip), message: `Gusts to ${mph} mph with ${rainLabel} (${maxPrecip.toFixed(1)} mm/h). ${windDesc}`, color, textColor, // Wind + rain: rain outranks cloud on the day tabs, so the blue ramp again. tint: rainTint(maxPrecip), type: 'weather', nightOnly: false, isoRange, }; } 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: '❄️', title: minTemp <= 0 ? 'Freezing Conditions' : 'Frost Risk Tonight', message: minTemp <= 0 ? `Temperatures dropping to ${minTemp.toFixed(1)}°C — ice on roads and surfaces is likely. Allow extra travel time.` : `Temperatures near freezing tonight (${minTemp.toFixed(1)}°C) — frost possible on exposed surfaces and vehicles.`, color: '#0a1a2a', textColor: '#c8e8ff', // Same cold blue a day tab would use for this temperature. tint: hexToRgb(utciCategory(minTemp).bg), type: 'weather', nightOnly: false, isoRange, // only show cell icon during the frosty hours }; }