2.2.1
Fix wet, windy and storms
This commit is contained in:
+24
-1
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${forecast && glanceSummary && glanceSummary.length > 0 && html`
|
||||
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html`
|
||||
<aside class="glance-rail">
|
||||
<div class="insight-panel insight-panel--glance">
|
||||
<div class="insight-panel-title">Today at a glance</div>
|
||||
@@ -913,6 +929,13 @@ export function UTCIForecast() {
|
||||
<span class="insight-value">${value}</span>
|
||||
</div>
|
||||
`)}
|
||||
${eventGlanceItems.map(({ icon, label, value, alert }) => html`
|
||||
<div class=${`insight-row${alert ? ' insight-row--alert' : ''}`} key=${`ev-${label}`}>
|
||||
<span class="insight-icon">${icon}</span>
|
||||
<span class="insight-label">${label}</span>
|
||||
<span class="insight-value">${value}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</aside>`}
|
||||
</div>
|
||||
|
||||
+5
-1
@@ -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),
|
||||
|
||||
@@ -81,13 +81,29 @@ export function getLensOverlaySVG(event, cx, cy, lensR) {
|
||||
+ '<path d="M ' + (cx-lensR+10) + ' ' + (cy+45) + ' Q ' + (cx-10) + ' ' + (cy+30) + ' ' + (cx+30) + ' ' + (cy+45) + ' Q ' + (cx+65) + ' ' + (cy+60) + ' ' + (cx+lensR-10) + ' ' + (cy+45) + '" fill="none" stroke="rgba(255,100,10,0.5)" stroke-width="2" /></g>';
|
||||
}
|
||||
|
||||
if (id === 'storm') {
|
||||
if (id === 'wet') {
|
||||
const sl = [-55,-30,-5,20,45,65].map(function(x){
|
||||
return '<line x1="' + (cx+x) + '" y1="' + (cy-lensR+10) + '" x2="' + (cx+x-15) + '" y2="' + (cy+lensR-10) + '" stroke="rgba(140,180,220,0.6)" stroke-width="1" stroke-linecap="round" />';
|
||||
}).join('');
|
||||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.45">' + sl + '</g>';
|
||||
}
|
||||
|
||||
if (id === 'wind') {
|
||||
const wl = [-30,5,40].map(function(y){
|
||||
return '<path d="M ' + (cx-lensR+12) + ' ' + (cy+y) + ' q 40 -14 80 0 q 30 10 ' + (lensR-10) + ' -2" fill="none" stroke="rgba(170,200,230,0.55)" stroke-width="1.5" stroke-linecap="round" />';
|
||||
}).join('');
|
||||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.45">' + wl + '</g>';
|
||||
}
|
||||
|
||||
if (id === 'storm') {
|
||||
// Driving rain streaks plus a forked lightning bolt.
|
||||
const sl = [-55,-30,-5,20,45,65].map(function(x){
|
||||
return '<line x1="' + (cx+x) + '" y1="' + (cy-lensR+10) + '" x2="' + (cx+x-22) + '" y2="' + (cy+lensR-10) + '" stroke="rgba(140,180,220,0.55)" stroke-width="1" stroke-linecap="round" />';
|
||||
}).join('');
|
||||
const bolt = '<path d="M ' + (cx+6) + ' ' + (cy-lensR+18) + ' L ' + (cx-14) + ' ' + (cy+8) + ' L ' + (cx-2) + ' ' + (cy+8) + ' L ' + (cx-18) + ' ' + (cy+lensR-14) + ' L ' + (cx+18) + ' ' + (cy-2) + ' L ' + (cx+4) + ' ' + (cy-2) + ' Z" fill="rgba(255,224,120,0.7)" stroke="rgba(255,200,60,0.8)" stroke-width="1" stroke-linejoin="round" />';
|
||||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">' + sl + bolt + '</g>';
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user