316 lines
13 KiB
JavaScript
316 lines
13 KiB
JavaScript
// ------------------------------------------------------------------------
|
|
// 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.
|
|
// 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
|
|
};
|
|
}
|
|
|
|
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.`,
|
|
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,
|
|
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,
|
|
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',
|
|
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,
|
|
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',
|
|
type: 'weather',
|
|
nightOnly: false,
|
|
isoRange, // only show cell icon during the frosty hours
|
|
};
|
|
}
|