From 72a4a3fe38a1fcff7b405d0db72f841a66aa7a20 Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Thu, 15 Oct 2026 14:00:41 -0400 Subject: [PATCH] Alerts tab: fix hurricane/tropical storm bugs; rich alert cards matching the dashboard Hurricane / tropical storm fixes - NHC intensity is in knots: show real mph (80 kt = 92 mph) instead of mislabelled 'mph' - Real Saffir-Simpson category from wind (was always cat 1); subtropical, post-tropical and potential-cyclone types no longer shown as 'depression'; each storm shows movement (direction + mph, previously a non-existent field), distance from you, update time, advisory link - Forecast track points coloured by their own forecast strength (not the current class) - Map click handlers registered once (popups stacked on every refresh); overlays wait for the map to settle instead of silently not drawing while tiles load; stale renders superseded - Subtropical/tropical advice added to alert guidance; escaped storm names Alert cards (active alerts only) - What it's for, area, window, time remaining, plain-language meaning, what to do, and a Full details expander (headline, text, instructions, severity/urgency/certainty, issuer) - No longer drops alerts beyond 6 per severity group; expired alerts are filtered out - Shared plain-language alert glossary (alert-info.js, 64 products incl. tropical and surge) now used by both the app and the dashboard history Co-Authored-By: Claude Sonnet 5.5 --- app/public/alert-info.js | 101 ++++++++++++ app/public/app-ui.css | 26 ++++ app/public/app.js | 319 ++++++++++++++++++++++++-------------- app/public/dashboard.html | 73 +-------- app/public/index.html | 1 + app/public/style.css | 15 +- app/public/sw.js | 3 +- app/server.js | 17 +- 8 files changed, 359 insertions(+), 196 deletions(-) create mode 100644 app/public/alert-info.js diff --git a/app/public/alert-info.js b/app/public/alert-info.js new file mode 100644 index 0000000..ea4495e --- /dev/null +++ b/app/public/alert-info.js @@ -0,0 +1,101 @@ +/* Shared plain-language alert helpers (used by the weather app and the station dashboard) */ +(() => { +'use strict'; +const GLOSSARY = { + 'heat advisory': 'Dangerously hot conditions expected. Limit time outdoors, drink plenty of water, and check on children, older adults and pets.', + 'excessive heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.', + 'excessive heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.', + 'extreme heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.', + 'extreme heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.', + 'severe thunderstorm watch': 'Storms capable of damaging wind or large hail are possible in the area. Stay alert and be ready to take shelter.', + 'severe thunderstorm warning': 'A severe storm with damaging wind and/or large hail is happening or imminent. Move indoors away from windows.', + 'tornado watch': 'Conditions are favorable for tornadoes. Stay alert and know where you would take shelter.', + 'tornado warning': 'A tornado is occurring or imminent. Take shelter immediately in the lowest, most interior room.', + 'special weather statement': 'A short notice about a significant but non-warning weather event, often a strong storm, gusty winds, dense fog or sudden changes.', + 'flood watch': 'Flooding is possible. Be prepared to move to higher ground and avoid low-lying areas.', + 'flood advisory': 'Minor flooding is occurring or expected. Avoid flooded roads and low spots.', + 'flood warning': 'Flooding is happening or imminent. Move to higher ground and never drive through flood water.', + 'flash flood watch': 'Flash flooding is possible. Be ready to move to higher ground quickly.', + 'flash flood warning': 'Flash flooding is occurring or imminent. Move to higher ground now.', + 'wind advisory': 'Sustained or gusty winds that can blow around unsecured objects and make driving difficult for high-profile vehicles.', + 'high wind watch': 'Damaging winds are possible. Secure outdoor objects.', + 'high wind warning': 'Damaging winds are occurring or imminent. Secure objects and avoid travel if possible.', + 'freeze watch': 'A freeze is possible. Protect sensitive plants, pets and exposed pipes.', + 'freeze warning': 'Temperatures at or below freezing are expected. Protect plants, pets and exposed pipes.', + 'frost advisory': 'Frost is expected and can damage sensitive plants.', + 'hard freeze warning': 'Severe freezing temperatures are expected. Protect plants, pets and pipes.', + 'winter storm watch': 'Heavy snow and/or ice is possible. Prepare for hazardous travel.', + 'winter storm warning': 'Heavy snow and/or ice is occurring or imminent. Travel may be dangerous or impossible.', + 'winter weather advisory': 'Snow, sleet or freezing rain will cause slippery roads and travel difficulty.', + 'ice storm warning': 'Significant ice accumulation is expected. Power outages and dangerous travel are likely.', + 'blizzard warning': 'Heavy snow with strong winds and very low visibility. Avoid travel.', + 'wind chill advisory': 'Dangerously cold wind chills are expected. Dress in layers and limit time outside.', + 'wind chill warning': 'Life-threatening wind chills are expected. Frostbite can occur quickly.', + 'dense fog advisory': 'Fog is reducing visibility to a quarter mile or less. Slow down and use low-beam headlights.', + 'dense smoke advisory': 'Smoke is reducing visibility and air quality.', + 'air quality alert': 'Air quality is unhealthy for some or all people. Limit prolonged outdoor exertion.', + 'red flag warning': 'Dry, windy and warm conditions create a high wildfire risk. Avoid open flames and outdoor burning.', + 'fire weather watch': 'Conditions may soon create a high wildfire risk.', + 'rip current statement': 'Dangerous rip currents at the beach. Stay out of the water or swim near a lifeguard.', + 'coastal flood advisory': 'Minor coastal flooding is expected.', + 'coastal flood warning': 'Significant coastal flooding is occurring or imminent.', + 'small craft advisory': 'Winds or seas hazardous to small boats.', + 'tropical storm watch': 'Tropical storm conditions are possible within about 48 hours.', + 'tropical storm warning': 'Tropical storm conditions are expected within about 36 hours.', + 'hurricane watch': 'Hurricane conditions are possible within about 48 hours.', + 'hurricane warning': 'Hurricane conditions are expected within about 36 hours.', + 'hazardous weather outlook': 'An outlook of possible hazardous weather in the next several days.', + 'severe weather statement': 'An update on an ongoing severe storm warning.', + 'flood statement': 'An update on an ongoing flood warning.', + 'storm surge watch': 'Life-threatening storm surge flooding is possible within about 48 hours. Know your evacuation zone and be ready to leave.', + 'storm surge warning': 'Life-threatening storm surge flooding is expected within about 36 hours. Follow evacuation orders from local officials immediately.', + 'hurricane local statement': 'Local details on a hurricane threat: expected winds, rain, surge and tornado risk for your area, and what officials are advising.', + 'tropical cyclone statement': 'Local details on a tropical storm or hurricane threat for your area.', + 'tropical depression watch': 'A tropical depression may affect the area; heavy rain and gusty wind are possible.', + 'extreme wind warning': 'Extreme, hurricane-force winds (115+ mph) are about to hit. Take shelter now in the strongest part of a sturdy building.', + 'hurricane force wind watch': 'Hurricane-force winds are possible over marine areas. Stay in port.', + 'hurricane force wind warning': 'Hurricane-force winds are expected over marine areas. Dangerous for all vessels.', + 'storm warning': 'Strong marine winds (48–63 knots) expected. Dangerous for all vessels.', + 'gale warning': 'Strong marine winds (34–47 knots) expected. Hazardous for small and large vessels.', + 'gale watch': 'Gale-force marine winds are possible.', + 'special marine warning': 'Short-lived, hazardous marine weather (strong winds, waterspouts, hail) is happening. Seek safe harbor.', + 'high surf advisory': 'Large breaking waves make swimming and surfing dangerous.', + 'high surf warning': 'Very large breaking waves are expected: dangerous and life-threatening at the shore.', + 'beach hazards statement': 'Hazards at the beach such as rip currents or high surf. Swim near a lifeguard.', + 'lake wind advisory': 'Gusty lake winds can create hazardous waves for small boats.', + 'dust storm warning': 'Blowing dust is reducing visibility to near zero. Pull off the road and turn off your lights.', + 'tornado emergency': 'A confirmed large and destructive tornado is moving toward a populated area. Take cover immediately.', + 'flash flood emergency': 'Catastrophic, life-threatening flash flooding is happening. Move to higher ground now.', +}; + +function meaning(event) { + const e = String(event || '').toLowerCase().trim(); + if (GLOSSARY[e]) return GLOSSARY[e]; + if (e.endsWith('watch')) return 'A Watch: conditions are favorable for this hazard. Stay alert and be ready to act.'; + if (e.endsWith('warning')) return 'A Warning: this hazard is happening or imminent. Take action now.'; + if (e.endsWith('advisory')) return 'An Advisory: conditions may cause inconvenience or danger if you are not careful.'; + if (e.endsWith('statement')) return 'An informational statement about developing or ongoing weather.'; + return ''; +} +// NWS text is often structured "* WHAT...", "* WHERE...", "* WHEN..."; lead with the WHAT part +function firstSentence(t, max = 220) { + const what = String(t || '').match(/\*\s*WHAT\.\.\.([\s\S]*?)(?:\n\s*\n|\*\s*[A-Z ]+\.\.\.|$)/); + const c = (what ? what[1] : String(t || '')).replace(/\s+/g, ' ').trim(); + const m = c.match(/^.*?[.!?](\s|$)/); + const r = (m ? m[0] : c).trim(); + return r.length > max ? r.slice(0, max - 3) + '…' : r; +} +const area = (a) => String(a || '').replace(/;\s*/g, ', '); +const when = (iso) => (iso ? new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''); +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +// "ends in 3h 10m" / "ended" for active alerts +function remaining(iso) { + if (!iso) return ''; + const ms = Date.parse(iso) - Date.now(); + if (!Number.isFinite(ms)) return ''; + if (ms <= 0) return 'expired'; + const m = Math.round(ms / 60000); + return m < 60 ? `ends in ${m} min` : m < 1440 ? `ends in ${Math.floor(m / 60)}h ${m % 60}m` : `ends in ${Math.round(m / 144) / 10}d`.replace('.0d', 'd'); +} +window.AlertInfo = { meaning, firstSentence, area, when, esc, remaining, GLOSSARY }; +})(); diff --git a/app/public/app-ui.css b/app/public/app-ui.css index 18910e4..6322ad7 100644 --- a/app/public/app-ui.css +++ b/app/public/app-ui.css @@ -298,3 +298,29 @@ body.map-fs #tab-unified-map { padding: 0; } #unified-map .maplibregl-ctrl-top-right { margin-top: calc(var(--hud-top) + 166px); margin-right: max(var(--hud-side), env(safe-area-inset-right, 0px)); } #unified-map .maplibregl-ctrl-top-right .maplibregl-ctrl { margin: 0; } @media (pointer: coarse) { #unified-map .maplibregl-ctrl-top-right { display: none; } } + +/* ── Alerts tab: hurricane cards + rich alert cards ── */ +.hurricane-storm-card { border-radius: 16px; padding: 0.9rem 1rem; } +.hurricane-name { font-weight: 800; font-size: 1.05rem; } +.hurricane-stats b { color: var(--ink); font-size: 0.95rem; } +.hurricane-stats small { opacity: .75; } +.hurricane-meta { display: flex; flex-wrap: wrap; gap: 0.3rem 0.9rem; align-items: center; margin-top: 0.5rem; font-size: 0.74rem; color: var(--ink-light); } +.hurricane-link { color: var(--accent); font-weight: 600; text-decoration: none; } +.hurricane-link:hover { text-decoration: underline; } +.hurricane-map-hint { margin-left: auto; color: var(--accent); font-weight: 600; } + +.alert-rich { border-radius: 16px; padding: 0.95rem 1.05rem; } +.alert-why { font-size: 0.95rem; font-weight: 600; line-height: 1.4; margin: 0.35rem 0 0.5rem; } +.alert-facts { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; font-size: 0.8rem; color: var(--ink-light); margin-bottom: 0.55rem; } +.alert-left { font-weight: 700; color: var(--accent); } +.alert-meaning { font-size: 0.84rem; line-height: 1.45; padding: 0.55rem 0.75rem; border-radius: 12px; background: color-mix(in srgb, var(--accent) 9%, transparent); margin-bottom: 0.5rem; } +.alert-rich .alert-action { font-size: 0.86rem; line-height: 1.45; } +.alert-more { margin-top: 0.6rem; border-top: 1px dashed var(--border); padding-top: 0.5rem; } +.alert-more summary { cursor: pointer; font-size: 0.8rem; font-weight: 700; color: var(--accent); min-height: 36px; display: flex; align-items: center; list-style: none; } +.alert-more summary::-webkit-details-marker { display: none; } +.alert-more summary::before { content: '▸'; margin-right: 0.4rem; transition: transform .15s; } +.alert-more[open] summary::before { transform: rotate(90deg); } +.alert-full { font-size: 0.84rem; line-height: 1.5; } +.alert-full p { margin: 0 0 0.6rem; } +.alert-pre { white-space: pre-wrap; } +.alert-meta { font-size: 0.74rem; color: var(--ink-light); } diff --git a/app/public/app.js b/app/public/app.js index 3622108..28da076 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -62,55 +62,104 @@ function parseNhcCoord(str) { let hurricaneStorms = []; +// ── NHC storm model ────────────────────────────────────────────────────────── +// NHC's CurrentStorms.json reports `intensity` in KNOTS and `movementSpeed` in mph. +const KT_TO_MPH = 1.15078; +const STORM_CLASS = { + TD: 'Tropical Depression', TS: 'Tropical Storm', HU: 'Hurricane', TY: 'Typhoon', + STD: 'Subtropical Depression', STS: 'Subtropical Storm', SD: 'Subtropical Depression', SS: 'Subtropical Storm', + PTC: 'Potential Tropical Cyclone', PC: 'Post-Tropical Cyclone', EX: 'Extratropical Cyclone', +}; +const SAFFIR_COLORS = { td: '#5dade2', ts: '#27ae60', 1: '#f1c40f', 2: '#f39c12', 3: '#e74c3c', 4: '#c0392b', 5: '#9b59b6', ex: '#95a5a6' }; +const compass16 = deg => ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(deg / 22.5) % 16]; +function saffirCategory(kt) { return kt >= 137 ? 5 : kt >= 113 ? 4 : kt >= 96 ? 3 : kt >= 83 ? 2 : kt >= 64 ? 1 : 0; } +// Colour for a point on the forecast track, from its own forecast wind (knots) +function windColor(kt) { return kt >= 64 ? SAFFIR_COLORS[Math.max(1, saffirCategory(kt))] : kt >= 34 ? SAFFIR_COLORS.ts : SAFFIR_COLORS.td; } +function escHtml(v) { return String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } +function fmtLatLon(lat, lon) { + const la = isNaN(lat) ? '--' : `${Math.abs(lat).toFixed(1)}°${lat < 0 ? 'S' : 'N'}`; + const lo = isNaN(lon) ? '--' : `${Math.abs(lon).toFixed(1)}°${lon < 0 ? 'W' : 'E'}`; + return `${la}, ${lo}`; +} +function milesBetween(lat1, lon1, lat2, lon2) { + const R = 3958.8, r = Math.PI / 180, dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r; + const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * r) * Math.cos(lat2 * r) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(x)); +} +function bearingDeg(lat1, lon1, lat2, lon2) { + const r = Math.PI / 180, y = Math.sin((lon2 - lon1) * r) * Math.cos(lat2 * r); + const x = Math.cos(lat1 * r) * Math.sin(lat2 * r) - Math.sin(lat1 * r) * Math.cos(lat2 * r) * Math.cos((lon2 - lon1) * r); + return (Math.atan2(y, x) / r + 360) % 360; +} + +function stormInfo(s) { + const cls = String(s.classification || '').toUpperCase(); + const kt = parseFloat(s.intensity); + const hasKt = Number.isFinite(kt); + const mph = hasKt ? Math.round(kt * KT_TO_MPH) : null; + const isHurr = cls === 'HU' || cls === 'TY'; + const cat = isHurr && hasKt ? Math.max(1, saffirCategory(kt)) : isHurr ? 1 : 0; + const base = STORM_CLASS[cls] || 'Tropical Cyclone'; + const label = cat ? `Category ${cat} ${cls === 'TY' ? 'Typhoon' : 'Hurricane'}${cat >= 3 ? ' (major)' : ''}` : base; + const badge = cat ? `cat-${cat}` : (cls === 'TS' || cls === 'STS' || cls === 'SS') ? 'cat-ts' : (cls === 'TD' || cls === 'STD' || cls === 'SD' || cls === 'PTC') ? 'cat-td' : 'cat-ex'; + const color = cat ? SAFFIR_COLORS[cat] : badge === 'cat-ts' ? SAFFIR_COLORS.ts : badge === 'cat-td' ? SAFFIR_COLORS.td : SAFFIR_COLORS.ex; + const lat = Number.isFinite(s.latitudeNumeric) ? s.latitudeNumeric : parseNhcCoord(s.latitude); + const lon = Number.isFinite(s.longitudeNumeric) ? s.longitudeNumeric : parseNhcCoord(s.longitude); + const title = s.name ? `${base} ${s.name}` : base; + let movement = ''; + if (Number.isFinite(s.movementSpeed)) { + movement = s.movementSpeed < 1 ? 'Nearly stationary' + : `Moving ${Number.isFinite(s.movementDir) ? compass16(s.movementDir) + ' ' : ''}at ${Math.round(s.movementSpeed)} mph`; + } + return { cls, kt: hasKt ? kt : null, mph, cat, label, base, title, badge, color, lat, lon, movement }; +} + function renderHurricanePanel(storms) { const panel = document.getElementById('hurricane-panel'); if (!panel) return; if (!storms || !storms.length) { panel.innerHTML = ''; return; } hurricaneStorms = storms; - const catLabel = c => { - if (!c) return ''; - const up = c.toUpperCase(); - if (up === 'HU') return 'cat-1'; - if (up === 'TS') return 'cat-ts'; - if (up === 'TD') return 'cat-td'; - const m = c.match(/(\d)/); - if (m) return `cat-${m[1]}`; - return 'cat-td'; - }; - const catName = c => { - if (!c) return 'Tropical'; - const up = c.toUpperCase(); - if (up === 'HU') return 'Hurricane'; - if (up === 'TS') return 'Tropical Storm'; - if (up === 'TD') return 'Tropical Depression'; - return c; - }; + const here = state.currentCity; panel.innerHTML = storms.map(s => { - const lat = parseNhcCoord(s.latitude); - const lon = parseNhcCoord(s.longitude); + const i = stormInfo(s); + let away = ''; + if (here && !isNaN(i.lat) && !isNaN(i.lon)) { + const mi = milesBetween(here.latitude, here.longitude, i.lat, i.lon); + const dir = compass16(bearingDeg(here.latitude, here.longitude, i.lat, i.lon)); + away = `≈ ${Math.round(state.units === 'us' ? mi : mi * 1.60934).toLocaleString()} ${state.units === 'us' ? 'mi' : 'km'} ${dir} of ${escHtml(here.name || 'you')}`; + } + const updated = s.lastUpdate ? new Date(s.lastUpdate).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) : ''; + const adv = s.publicAdvisory; return ` -
+
- ${s.name || 'Storm'} - ${catName(s.classification)} + ${escHtml(i.title)} + ${escHtml(i.cat ? 'Category ' + i.cat : i.base)}
- Winds: ${s.intensity || '--'} mph - ${s.pressure ? `Pressure: ${s.pressure} mb` : ''} - ${isNaN(lat) ? s.latitude : lat.toFixed(1)+'°'}, ${isNaN(lon) ? s.longitude : Math.abs(lon).toFixed(1)+'°'+(lon < 0 ? 'W' : 'E')} - ${s.movement ? `Moving ${s.movement}` : ''} - 🗺 View on map + ${i.mph != null ? i.mph + ' mph' : '--'} winds${i.kt != null ? ` (${Math.round(i.kt)} kt)` : ''} + ${s.pressure ? `${escHtml(s.pressure)} mb` : ''} + ${fmtLatLon(i.lat, i.lon)} + ${i.movement ? `${escHtml(i.movement)}` : ''} + ${away}
-
- `}).join(''); - panel.querySelectorAll('.hurricane-storm-card').forEach((card, i) => { - card.addEventListener('click', () => flyToHurricane(hurricaneStorms[i])); +
+ ${updated ? `Updated ${escHtml(updated)}` : ''} + ${adv?.advNum ? `Advisory ${escHtml(adv.advNum)}` : ''} + ${adv?.url ? `Official advisory ↗` : ''} + 🗺 View on map +
+
`; + }).join(''); + panel.querySelectorAll('.hurricane-storm-card').forEach((card, idx) => { + card.addEventListener('click', () => flyToHurricane(hurricaneStorms[idx])); + card.querySelector('.hurricane-link')?.addEventListener('click', e => e.stopPropagation()); }); } async function flyToHurricane(storm) { - const lat = parseNhcCoord(storm.latitude); - const lon = parseNhcCoord(storm.longitude); + const info = stormInfo(storm); + const { lat, lon } = info; if (isNaN(lat) || isNaN(lon)) return; activateTab('unified-map'); @@ -129,12 +178,10 @@ async function flyToHurricane(storm) { map.flyTo({ center: [lon, lat], zoom: 4, duration: 900 }); - const cat = (storm.classification || '').toUpperCase(); - const label = cat === 'HU' ? 'Hurricane' : cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression'; setTimeout(() => { - new maplibregl.Popup({ closeButton: true, maxWidth: '240px' }) + new maplibregl.Popup({ closeButton: true, maxWidth: '260px' }) .setLngLat([lon, lat]) - .setHTML(`${storm.name || 'Storm'}
${label}
Winds: ${storm.intensity || '--'} mph${storm.movement ? '
Moving ' + storm.movement : ''}`) + .setHTML(`${escHtml(info.title)}
${escHtml(info.label)}
Winds: ${info.mph != null ? info.mph + ' mph' : '--'}${info.movement ? '
' + escHtml(info.movement) : ''}`) .addTo(map); }, 950); @@ -158,20 +205,22 @@ async function flyToHurricane(storm) { if (lin?.type === 'FeatureCollection' && lin.features?.length) { map.addSource('hurricane-track-lin', { type: 'geojson', data: lin }); map.addLayer({ id: 'hurricane-track-line', type: 'line', source: 'hurricane-track-lin', - paint: { 'line-color': '#ff5722', 'line-width': 2.5, 'line-dasharray': [4, 2] } }); + paint: { 'line-color': '#ff7043', 'line-width': 2.5, 'line-dasharray': [4, 2] } }); } if (pts?.type === 'FeatureCollection' && pts.features?.length) { map.addSource('hurricane-track-pts-src', { type: 'geojson', data: pts }); - map.addLayer({ id: 'hurricane-track-pts', type: 'circle', source: 'hurricane-track-pts-src', + // Each forecast point is coloured by its own forecast strength (not the storm's current class) + map.addLayer({ + id: 'hurricane-track-pts', type: 'circle', source: 'hurricane-track-pts-src', paint: { 'circle-radius': 7, - 'circle-color': ['match', ['get', 'TCDVLP'], - 'Hurricane', '#f44336', 'HU', '#f44336', - 'Tropical Storm', '#9c27b0', 'TS', '#9c27b0', - '#2196f3'], + 'circle-color': ['step', ['to-number', ['get', 'MAXWIND'], 0], + SAFFIR_COLORS.td, 34, SAFFIR_COLORS.ts, 64, SAFFIR_COLORS[1], 83, SAFFIR_COLORS[2], + 96, SAFFIR_COLORS[3], 113, SAFFIR_COLORS[4], 137, SAFFIR_COLORS[5]], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff', - } }); + }, + }); const allCoords = pts.features.map(f => f.geometry.coordinates); if (allCoords.length > 1) { @@ -183,24 +232,25 @@ async function flyToHurricane(storm) { ); } - map.on('click', 'hurricane-track-pts', e => { - const p = e.features[0].properties; - const tau = p.TAU != null ? `+${p.TAU}h` : ''; - const knots = p.MAXWIND || p.INTENSITY; - const wind = knots ? `${Math.round(knots * 1.15)} mph` : '--'; - const type = p.TCDVLP || p.STORMTYPE || ''; - new maplibregl.Popup().setLngLat(e.lngLat) - .setHTML(`${tau} ${type}
Winds: ${wind}${p.MSLP ? '
Pressure: ' + p.MSLP + ' mb' : ''}`) - .addTo(map); - }); - map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; }); - map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; }); + // Register the handlers once per map: re-adding them on every call stacked up duplicate popups + if (!map.__trackHandlers) { + map.__trackHandlers = true; + map.on('click', 'hurricane-track-pts', e => { + const p = e.features[0].properties; + const when = p.TAU != null ? (p.TAU === 0 ? 'Now' : `+${p.TAU}h`) : ''; + const kt = Number(p.MAXWIND) || 0; + const cat = saffirCategory(kt); + const kind = cat ? `Category ${cat} hurricane` : kt >= 34 ? 'Tropical storm' : 'Tropical depression'; + new maplibregl.Popup().setLngLat(e.lngLat) + .setHTML(`${escHtml(when)} ${escHtml(kind)}
Winds: ${kt ? Math.round(kt * KT_TO_MPH) + ' mph (' + kt + ' kt)' : '--'}`) + .addTo(map); + }); + map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; }); + map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; }); + } } } catch {} } - - // Render overlay last so storm circles sit on top of the track layers - await renderHurricaneOverlay(map); } function renderPrecipChart(h, startIdx) { @@ -264,8 +314,21 @@ async function subscribePush() { } } +// Overlays used to bail out silently whenever tiles were still loading (most of the time while radar animates). +// Wait for the map to settle instead (but never hang: give up after a few seconds and try anyway). +function mapReady(map, timeoutMs = 4000) { + if (!map || map.loaded()) return Promise.resolve(); + return new Promise(resolve => { + const t = setTimeout(resolve, timeoutMs); + map.once('idle', () => { clearTimeout(t); resolve(); }); + }); +} + async function renderHurricaneOverlay(map) { - if (!map || !map.loaded()) return; + if (!map) return; + const tok = (map.__hurrTok = (map.__hurrTok || 0) + 1); // a newer refresh supersedes this one + await mapReady(map); + if (tok !== map.__hurrTok) return; ['hurricane-overlay'].forEach(id => { if (map.getLayer(id)) map.removeLayer(id); if (map.getSource(id)) map.removeSource(id); @@ -273,17 +336,16 @@ async function renderHurricaneOverlay(map) { if (!state.unifiedMapLayers.hurricane) return; try { const data = await fetch('/api/hurricanes').then(r => r.json()); + if (tok !== map.__hurrTok) return; const storms = data.storms || []; if (!storms.length) return; const features = storms.map(s => { - const lat = parseNhcCoord(s.latitude); - const lon = parseNhcCoord(s.longitude); - if (isNaN(lat) || isNaN(lon)) return null; - const cat = (s.classification || '').toUpperCase(); + const i = stormInfo(s); + if (isNaN(i.lat) || isNaN(i.lon)) return null; return { type: 'Feature', - geometry: { type: 'Point', coordinates: [lon, lat] }, - properties: { name: s.name || 'Storm', cat, intensity: s.intensity || '--' }, + geometry: { type: 'Point', coordinates: [i.lon, i.lat] }, + properties: { title: i.title, label: i.label, mph: i.mph, kt: i.kt, color: i.color, movement: i.movement }, }; }).filter(Boolean); map.addSource('hurricane-overlay', { type: 'geojson', data: { type: 'FeatureCollection', features } }); @@ -291,25 +353,29 @@ async function renderHurricaneOverlay(map) { id: 'hurricane-overlay', type: 'circle', source: 'hurricane-overlay', paint: { 'circle-radius': 14, - 'circle-color': ['match', ['get', 'cat'], 'HU', '#f44336', 'TS', '#9c27b0', '#2196f3'], + 'circle-color': ['get', 'color'], 'circle-opacity': 0.55, 'circle-stroke-width': 2.5, - 'circle-stroke-color': ['match', ['get', 'cat'], 'HU', '#f44336', 'TS', '#9c27b0', '#2196f3'], + 'circle-stroke-color': ['get', 'color'], }, }); - map.on('click', 'hurricane-overlay', e => { - const p = e.features[0].properties; - const label = p.cat === 'HU' ? 'Hurricane' : p.cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression'; - new maplibregl.Popup().setLngLat(e.lngLat) - .setHTML(`${p.name}
${label}
Winds: ${p.intensity} mph`).addTo(map); - }); - map.on('mouseenter', 'hurricane-overlay', () => { map.getCanvas().style.cursor = 'pointer'; }); - map.on('mouseleave', 'hurricane-overlay', () => { map.getCanvas().style.cursor = ''; }); + // Handlers are registered once; this overlay is rebuilt on every map refresh + if (!map.__stormOverlayHandlers) { + map.__stormOverlayHandlers = true; + map.on('click', 'hurricane-overlay', e => { + const p = e.features[0].properties; + new maplibregl.Popup().setLngLat(e.lngLat) + .setHTML(`${escHtml(p.title)}
${escHtml(p.label)}
Winds: ${p.mph != null && p.mph !== 'null' ? p.mph + ' mph' : '--'}${p.movement ? '
' + escHtml(p.movement) : ''}`).addTo(map); + }); + map.on('mouseenter', 'hurricane-overlay', () => { map.getCanvas().style.cursor = 'pointer'; }); + map.on('mouseleave', 'hurricane-overlay', () => { map.getCanvas().style.cursor = ''; }); + } } catch {} } async function renderWildfireOverlay(map) { - if (!map || !map.loaded()) return; + if (!map) return; + await mapReady(map); ['wildfire-fill', 'wildfire-outline'].forEach(id => { if (map.getLayer(id)) map.removeLayer(id); }); @@ -334,7 +400,8 @@ async function renderWildfireOverlay(map) { } async function renderSPCOverlay(map) { - if (!map || !map.loaded()) return; + if (!map) return; + await mapReady(map); ['spc-overlay'].forEach(id => { if (map.getLayer(id)) map.removeLayer(id); if (map.getSource(id)) map.removeSource(id); @@ -1693,6 +1760,10 @@ function severityRank(severity) { function plainAlertAction(eventName, severity) { const eventText = String(eventName || '').toLowerCase(); const sev = normalizeSeverity(severity); + if (eventText.includes('storm surge')) return 'Know your evacuation zone. If officials order an evacuation, leave early: surge is the deadliest part of a tropical storm.'; + if (eventText.includes('hurricane') || eventText.includes('typhoon')) return eventText.includes('warning') ? 'Finish storm preparations now, follow local evacuation orders, and plan to shelter until the storm has fully passed.' : 'Review your plan, stock water, food and medicine, secure outdoor items, and know your evacuation zone.'; + if (eventText.includes('tropical')) return eventText.includes('warning') ? 'Expect damaging winds and heavy rain. Secure outdoor items, charge devices, and stay off the roads once conditions deteriorate.' : 'Tropical storm conditions are possible. Prepare now: secure outdoor items and keep supplies ready.'; + if (eventText.includes('extreme wind')) return 'Take shelter immediately in the strongest part of a sturdy building, away from windows.'; if (eventText.includes('tornado')) return 'Move to an interior room on the lowest level away from windows immediately.'; if (eventText.includes('thunderstorm')) return 'Pause outdoor activity, secure loose items, and be ready to shelter indoors quickly.'; if (eventText.includes('flood')) return 'Avoid low-water crossings and reroute away from flood-prone roads.'; @@ -1707,10 +1778,9 @@ function plainAlertAction(eventName, severity) { function buildAlertTiming(p) { const start = p?.onset || p?.effective || p?.sent; - const end = p?.expires; - const startTxt = start ? formatClock(start) : 'now'; - const endTxt = end ? formatClock(end) : 'until further notice'; - return `${startTxt} - ${endTxt}`; + const end = p?.expires || p?.ends; + const fmt = iso => new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + return `${start ? fmt(start) : 'now'} → ${end ? fmt(end) : 'until further notice'}`; } function buildStormEtaText() { @@ -1902,13 +1972,60 @@ function showShareWeatherStatus(msg) { }, 2200); } +// One active alert: what it's for, where, when, what to do, and the full text on demand +function buildAlertCard(f) { + const p = f.properties || {}; + const AI = window.AlertInfo; + const sev = normalizeSeverity(p.severity); + const meaning = AI.meaning(p.event); + const why = p.description ? AI.firstSentence(p.description) : (p.headline || 'See official sources for details.'); + const areaAll = AI.area(p.areaDesc); + const parts = String(p.areaDesc || '').split(';').map(x => x.trim()).filter(Boolean); + const areaShort = parts.length > 4 ? `${parts.slice(0, 4).join(', ')} +${parts.length - 4} more` : areaAll; + const left = AI.remaining(p.expires || p.ends); + const todo = p.instruction ? AI.firstSentence(p.instruction, 260) : plainAlertAction(p.event, p.severity); + const meta = [p.severity && p.severity !== 'Unknown' && `Severity: ${p.severity}`, p.urgency && p.urgency !== 'Unknown' && `Urgency: ${p.urgency}`, + p.certainty && p.certainty !== 'Unknown' && `Certainty: ${p.certainty}`, p.senderName && `Issued by ${p.senderName}`].filter(Boolean); + + const card = document.createElement('article'); + card.className = 'alert-item alert-rich'; + card.innerHTML = ` +
+ ${AI.esc(p.event || 'Alert')} + ${sev} +
+
${AI.esc(why)}
+
+ ${areaShort ? `📍 ${AI.esc(areaShort)}` : ''} + 🕑 ${AI.esc(buildAlertTiming(p))} + ${left ? `⏳ ${AI.esc(left)}` : ''} +
+ ${meaning ? `
What this means: ${AI.esc(meaning)}
` : ''} +
What to do: ${AI.esc(todo)}
+
+ Full details +
+ ${p.headline ? `

Headline: ${AI.esc(p.headline)}

` : ''} + ${areaAll ? `

Area: ${AI.esc(areaAll)}

` : ''} + ${p.description ? `

Details:\n${AI.esc(p.description.trim())}

` : ''} + ${p.instruction ? `

Instructions:\n${AI.esc(p.instruction.trim())}

` : ''} + ${meta.length ? `

${meta.map(AI.esc).join(' · ')}

` : ''} +
+
`; + return card; +} + function renderAlerts(data) { // Populate hurricane panel asynchronously fetch('/api/hurricanes').then(r => r.json()).then(d => renderHurricanePanel(d.storms || [])).catch(() => {}); const wrap = document.getElementById('alerts-wrap'); const list = document.getElementById('alerts-list'); - const features = data?.features || []; + // Only alerts that are still active (a cached response can include ones that have just expired) + const features = (data?.features || []).filter(f => { + const end = Date.parse(f.properties?.expires || f.properties?.ends || ''); + return !Number.isFinite(end) || end > Date.now(); + }); list.innerHTML = ''; if (!features.length) { @@ -1947,39 +2064,7 @@ function renderAlerts(data) { head.appendChild(hint); groupWrap.appendChild(head); - grouped.get(severity).slice(0, 6).forEach(f => { - const p = f.properties || {}; - const card = document.createElement('article'); - card.className = 'alert-item'; - - const top = document.createElement('div'); - top.className = 'alert-top'; - const event = document.createElement('strong'); - event.textContent = p.event || 'Alert'; - const sev = document.createElement('span'); - sev.className = `alert-severity severity-${normalizeSeverity(p.severity).toLowerCase()}`; - sev.textContent = normalizeSeverity(p.severity); - top.appendChild(event); - top.appendChild(sev); - - const timing = document.createElement('div'); - timing.className = 'alert-timing'; - timing.textContent = `Window: ${buildAlertTiming(p)}`; - - const msg = document.createElement('div'); - msg.className = 'alert-message'; - msg.textContent = p.headline || p.description || 'See official sources for details.'; - - const action = document.createElement('div'); - action.className = 'alert-action'; - action.textContent = `What this means for me: ${plainAlertAction(p.event, p.severity)}`; - - card.appendChild(top); - card.appendChild(timing); - card.appendChild(msg); - card.appendChild(action); - groupWrap.appendChild(card); - }); + grouped.get(severity).forEach(f => groupWrap.appendChild(buildAlertCard(f))); list.appendChild(groupWrap); }); diff --git a/app/public/dashboard.html b/app/public/dashboard.html index 71f1e1d..24f0932 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -17,6 +17,7 @@ +