Files
sunscope/assets/js/events/weather-checks.js
T
fraxle ede875f0d0 2.6
New Logo
Updated cache system now 15/30mins but scope updates every minute
Font update
2026-06-04 14:07:34 +01:00

266 lines
11 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
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';
// 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) - Beaufort Force 6 threshold
const GUST_STORM = 20; // m/s ≈ 45 mph - Beaufort Force 8+ threshold
const PRECIP_WET = 5; // mm/h - meaningful rain
const PRECIP_HEAVY = 10; // mm/h - heavy rain
// Beaufort scale lookup - force number and name from gust speed in m/s
function beaufortForce(ms) {
const mph = ms * 2.237;
if (mph >= 73) return { force: 12, name: 'Hurricane Force' };
if (mph >= 64) return { force: 11, name: 'Violent Storm' };
if (mph >= 55) return { force: 10, name: 'Storm Force' };
if (mph >= 47) return { force: 9, name: 'Strong Gale' };
if (mph >= 39) return { force: 8, name: 'Gale' };
if (mph >= 32) return { force: 7, name: 'Near Gale' };
return { force: 6, name: 'Strong Breeze' };
}
const BEAUFORT_DESC = {
6: 'Large branches in motion; umbrellas used with difficulty.',
7: 'Whole trees in motion; inconvenience felt when walking against the wind.',
8: 'Twigs break off trees and generally impedes progress.',
9: 'Slight structural damage possible — chimney pots and slates at risk.',
10: 'Trees uprooted; considerable structural damage expected.',
11: 'Very rarely experienced inland; widespread damage expected.',
12: 'Devastating conditions — avoid all outdoor activity.',
};
// Banner colour scales with severity
function windBannerColors(force) {
if (force >= 11) return { color: '#0c1020', textColor: '#c0d8ff' };
if (force >= 9) return { color: '#101828', textColor: '#c0d4f0' };
if (force >= 7) 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_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 { force, name } = beaufortForce(maxGust);
const desc = BEAUFORT_DESC[force] || '';
const { color, textColor } = windBannerColors(force);
const title = force >= 10 ? `${name} Winds`
: force >= 8 ? `${name} Warning`
: name;
return {
id: 'wind',
emoji: force >= 10 ? '🌪️' : '💨',
title,
message: `Gusts to ${mph} mph (Beaufort Force ${force}). ${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 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 { force, name } = beaufortForce(maxGust);
const { color, textColor } = windBannerColors(force);
const title = force >= 10 ? `${name} — Severe Storm`
: force >= 8 ? `${name} with Rain`
: 'Storm Conditions';
return {
id: 'storm',
emoji: '🌩️',
title,
message: `Gusts to ${mph} mph (Beaufort Force ${force}) with ${maxPrecip.toFixed(1)} mm/h rain. ${BEAUFORT_DESC[force] || ''} Take care outdoors.`,
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
};
}