// ════════════════════════════════════════════════════════════════════════ // 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). // ════════════════════════════════════════════════════════════════════════ export function checkStargazing(rows) { // Great stargazing: mostly clear night hours with low cloud const nightRows = rows.filter(r => r.elev < -5); if (nightRows.length < 3) return null; const avgCloud = nightRows.reduce((s, r) => s + r.cc, 0) / nightRows.length; if (avgCloud > 30) return null; return { id: 'stargazing', emoji: '⭐', title: 'Great Stargazing Tonight', message: `Clear skies expected overnight at ${nightRows.length} hours with average ${Math.round(avgCloud)}% cloud — ideal conditions for stargazing.`, color: '#080e1a', textColor: '#c8dcff', type: 'weather', nightOnly: true, }; } 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 }; } 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'; 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.`, extreme: `Extreme heat alert: ${maxTemp.toFixed(1)}°C forecast. Risk of heat stroke — avoid outdoor activity during peak hours.`, }; return { id: 'heat-spike', emoji: '🔥', title: severity === 'extreme' ? 'Extreme Heat Alert' : severity === 'severe' ? 'Heat Warning' : 'Heat Spike Today', message: msgs[severity], color: severity === 'extreme' ? '#3a0000' : severity === 'severe' ? '#4a1000' : '#5a2000', textColor: '#ffd0b0', type: 'weather', nightOnly: false, }; } 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; return { id: 'storm', emoji: '🌩️', title: isStorm ? 'Storm Conditions Forecast' : 'Blustery & Wet Today', message: isStorm ? `Storm-level conditions expected — gusts to ${maxGust.toFixed(0)} m/s with heavy precipitation. Take care outdoors.` : `Unsettled day ahead — windy with gusts to ${maxGust.toFixed(0)} m/s and ${maxPrecip.toFixed(1)} mm/h rain at peak.`, color: '#1a2030', textColor: '#c0d8f0', type: 'weather', nightOnly: false, }; } export function checkFrost(rows) { const minTemp = Math.min(...rows.map(r => r.Ta).filter(isFinite)); if (minTemp > 2) return null; 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', type: 'weather', nightOnly: false, }; }