Alerts tab: fix hurricane/tropical storm bugs; rich alert cards matching the dashboard
Publish Container / publish (push) Successful in 2m44s
Publish Container / publish (push) Successful in 2m44s
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8252017417
commit
72a4a3fe38
8 files changed
+359
-196
No files matched your search
+202
-117
@@ -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 = `<span>≈ ${Math.round(state.units === 'us' ? mi : mi * 1.60934).toLocaleString()} ${state.units === 'us' ? 'mi' : 'km'} ${dir} of ${escHtml(here.name || 'you')}</span>`;
|
||||
}
|
||||
const updated = s.lastUpdate ? new Date(s.lastUpdate).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) : '';
|
||||
const adv = s.publicAdvisory;
|
||||
return `
|
||||
<div class="hurricane-storm-card" style="cursor:pointer" title="Click to view on map">
|
||||
<div class="hurricane-storm-card" style="cursor:pointer" title="Click to view on map" data-storm="${escHtml(s.id || '')}">
|
||||
<div class="hurricane-header">
|
||||
<span class="hurricane-name">${s.name || 'Storm'}</span>
|
||||
<span class="hurricane-cat-badge ${catLabel(s.classification)}">${catName(s.classification)}</span>
|
||||
<span class="hurricane-name">${escHtml(i.title)}</span>
|
||||
<span class="hurricane-cat-badge ${i.badge}" style="background:${i.color}">${escHtml(i.cat ? 'Category ' + i.cat : i.base)}</span>
|
||||
</div>
|
||||
<div class="hurricane-stats">
|
||||
<span>Winds: ${s.intensity || '--'} mph</span>
|
||||
${s.pressure ? `<span>Pressure: ${s.pressure} mb</span>` : ''}
|
||||
<span>${isNaN(lat) ? s.latitude : lat.toFixed(1)+'°'}, ${isNaN(lon) ? s.longitude : Math.abs(lon).toFixed(1)+'°'+(lon < 0 ? 'W' : 'E')}</span>
|
||||
${s.movement ? `<span>Moving ${s.movement}</span>` : ''}
|
||||
<span style="color:var(--accent,#4da6ff);font-size:0.75em">🗺 View on map</span>
|
||||
<span><b>${i.mph != null ? i.mph + ' mph' : '--'}</b> winds${i.kt != null ? ` <small>(${Math.round(i.kt)} kt)</small>` : ''}</span>
|
||||
${s.pressure ? `<span>${escHtml(s.pressure)} mb</span>` : ''}
|
||||
<span>${fmtLatLon(i.lat, i.lon)}</span>
|
||||
${i.movement ? `<span>${escHtml(i.movement)}</span>` : ''}
|
||||
${away}
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
panel.querySelectorAll('.hurricane-storm-card').forEach((card, i) => {
|
||||
card.addEventListener('click', () => flyToHurricane(hurricaneStorms[i]));
|
||||
<div class="hurricane-meta">
|
||||
${updated ? `<span>Updated ${escHtml(updated)}</span>` : ''}
|
||||
${adv?.advNum ? `<span>Advisory ${escHtml(adv.advNum)}</span>` : ''}
|
||||
${adv?.url ? `<a href="${escHtml(adv.url)}" target="_blank" rel="noopener" class="hurricane-link">Official advisory ↗</a>` : ''}
|
||||
<span class="hurricane-map-hint">🗺 View on map</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).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(`<b>${storm.name || 'Storm'}</b><br>${label}<br>Winds: ${storm.intensity || '--'} mph${storm.movement ? '<br>Moving ' + storm.movement : ''}`)
|
||||
.setHTML(`<b>${escHtml(info.title)}</b><br>${escHtml(info.label)}<br>Winds: ${info.mph != null ? info.mph + ' mph' : '--'}${info.movement ? '<br>' + 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(`<b>${tau}</b> ${type}<br>Winds: ${wind}${p.MSLP ? '<br>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(`<b>${escHtml(when)}</b> ${escHtml(kind)}<br>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(`<b>${p.name}</b><br>${label}<br>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(`<b>${escHtml(p.title)}</b><br>${escHtml(p.label)}<br>Winds: ${p.mph != null && p.mph !== 'null' ? p.mph + ' mph' : '--'}${p.movement ? '<br>' + 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 = `
|
||||
<div class="alert-top">
|
||||
<strong>${AI.esc(p.event || 'Alert')}</strong>
|
||||
<span class="alert-severity severity-${sev.toLowerCase()}">${sev}</span>
|
||||
</div>
|
||||
<div class="alert-why">${AI.esc(why)}</div>
|
||||
<div class="alert-facts">
|
||||
${areaShort ? `<span>📍 ${AI.esc(areaShort)}</span>` : ''}
|
||||
<span>🕑 ${AI.esc(buildAlertTiming(p))}</span>
|
||||
${left ? `<span class="alert-left">⏳ ${AI.esc(left)}</span>` : ''}
|
||||
</div>
|
||||
${meaning ? `<div class="alert-meaning"><b>What this means:</b> ${AI.esc(meaning)}</div>` : ''}
|
||||
<div class="alert-action"><b>What to do:</b> ${AI.esc(todo)}</div>
|
||||
<details class="alert-more">
|
||||
<summary>Full details</summary>
|
||||
<div class="alert-full">
|
||||
${p.headline ? `<p><b>Headline:</b> ${AI.esc(p.headline)}</p>` : ''}
|
||||
${areaAll ? `<p><b>Area:</b> ${AI.esc(areaAll)}</p>` : ''}
|
||||
${p.description ? `<p class="alert-pre"><b>Details:</b>\n${AI.esc(p.description.trim())}</p>` : ''}
|
||||
${p.instruction ? `<p class="alert-pre"><b>Instructions:</b>\n${AI.esc(p.instruction.trim())}</p>` : ''}
|
||||
${meta.length ? `<p class="alert-meta">${meta.map(AI.esc).join(' · ')}</p>` : ''}
|
||||
</div>
|
||||
</details>`;
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user