Alerts tab: fix hurricane/tropical storm bugs; rich alert cards matching the dashboard
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:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 14:00:41 -04:00
1 parent 8252017417
commit 72a4a3fe38
8 files changed
+359 -196

No files matched your search

+101
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
})();
+26
View File
@@ -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 { 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; } #unified-map .maplibregl-ctrl-top-right .maplibregl-ctrl { margin: 0; }
@media (pointer: coarse) { #unified-map .maplibregl-ctrl-top-right { display: none; } } @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); }
+202 -117
View File
@@ -62,55 +62,104 @@ function parseNhcCoord(str) {
let hurricaneStorms = []; 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) { function renderHurricanePanel(storms) {
const panel = document.getElementById('hurricane-panel'); const panel = document.getElementById('hurricane-panel');
if (!panel) return; if (!panel) return;
if (!storms || !storms.length) { panel.innerHTML = ''; return; } if (!storms || !storms.length) { panel.innerHTML = ''; return; }
hurricaneStorms = storms; hurricaneStorms = storms;
const catLabel = c => { const here = state.currentCity;
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;
};
panel.innerHTML = storms.map(s => { panel.innerHTML = storms.map(s => {
const lat = parseNhcCoord(s.latitude); const i = stormInfo(s);
const lon = parseNhcCoord(s.longitude); 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 ` 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"> <div class="hurricane-header">
<span class="hurricane-name">${s.name || 'Storm'}</span> <span class="hurricane-name">${escHtml(i.title)}</span>
<span class="hurricane-cat-badge ${catLabel(s.classification)}">${catName(s.classification)}</span> <span class="hurricane-cat-badge ${i.badge}" style="background:${i.color}">${escHtml(i.cat ? 'Category ' + i.cat : i.base)}</span>
</div> </div>
<div class="hurricane-stats"> <div class="hurricane-stats">
<span>Winds: ${s.intensity || '--'} mph</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>Pressure: ${s.pressure} mb</span>` : ''} ${s.pressure ? `<span>${escHtml(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> <span>${fmtLatLon(i.lat, i.lon)}</span>
${s.movement ? `<span>Moving ${s.movement}</span>` : ''} ${i.movement ? `<span>${escHtml(i.movement)}</span>` : ''}
<span style="color:var(--accent,#4da6ff);font-size:0.75em">🗺 View on map</span> ${away}
</div> </div>
</div> <div class="hurricane-meta">
`}).join(''); ${updated ? `<span>Updated ${escHtml(updated)}</span>` : ''}
panel.querySelectorAll('.hurricane-storm-card').forEach((card, i) => { ${adv?.advNum ? `<span>Advisory ${escHtml(adv.advNum)}</span>` : ''}
card.addEventListener('click', () => flyToHurricane(hurricaneStorms[i])); ${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) { async function flyToHurricane(storm) {
const lat = parseNhcCoord(storm.latitude); const info = stormInfo(storm);
const lon = parseNhcCoord(storm.longitude); const { lat, lon } = info;
if (isNaN(lat) || isNaN(lon)) return; if (isNaN(lat) || isNaN(lon)) return;
activateTab('unified-map'); activateTab('unified-map');
@@ -129,12 +178,10 @@ async function flyToHurricane(storm) {
map.flyTo({ center: [lon, lat], zoom: 4, duration: 900 }); 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(() => { setTimeout(() => {
new maplibregl.Popup({ closeButton: true, maxWidth: '240px' }) new maplibregl.Popup({ closeButton: true, maxWidth: '260px' })
.setLngLat([lon, lat]) .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); .addTo(map);
}, 950); }, 950);
@@ -158,20 +205,22 @@ async function flyToHurricane(storm) {
if (lin?.type === 'FeatureCollection' && lin.features?.length) { if (lin?.type === 'FeatureCollection' && lin.features?.length) {
map.addSource('hurricane-track-lin', { type: 'geojson', data: lin }); map.addSource('hurricane-track-lin', { type: 'geojson', data: lin });
map.addLayer({ id: 'hurricane-track-line', type: 'line', source: 'hurricane-track-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) { if (pts?.type === 'FeatureCollection' && pts.features?.length) {
map.addSource('hurricane-track-pts-src', { type: 'geojson', data: pts }); 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: { paint: {
'circle-radius': 7, 'circle-radius': 7,
'circle-color': ['match', ['get', 'TCDVLP'], 'circle-color': ['step', ['to-number', ['get', 'MAXWIND'], 0],
'Hurricane', '#f44336', 'HU', '#f44336', SAFFIR_COLORS.td, 34, SAFFIR_COLORS.ts, 64, SAFFIR_COLORS[1], 83, SAFFIR_COLORS[2],
'Tropical Storm', '#9c27b0', 'TS', '#9c27b0', 96, SAFFIR_COLORS[3], 113, SAFFIR_COLORS[4], 137, SAFFIR_COLORS[5]],
'#2196f3'],
'circle-stroke-width': 2, 'circle-stroke-color': '#fff', 'circle-stroke-width': 2, 'circle-stroke-color': '#fff',
} }); },
});
const allCoords = pts.features.map(f => f.geometry.coordinates); const allCoords = pts.features.map(f => f.geometry.coordinates);
if (allCoords.length > 1) { if (allCoords.length > 1) {
@@ -183,24 +232,25 @@ async function flyToHurricane(storm) {
); );
} }
map.on('click', 'hurricane-track-pts', e => { // Register the handlers once per map: re-adding them on every call stacked up duplicate popups
const p = e.features[0].properties; if (!map.__trackHandlers) {
const tau = p.TAU != null ? `+${p.TAU}h` : ''; map.__trackHandlers = true;
const knots = p.MAXWIND || p.INTENSITY; map.on('click', 'hurricane-track-pts', e => {
const wind = knots ? `${Math.round(knots * 1.15)} mph` : '--'; const p = e.features[0].properties;
const type = p.TCDVLP || p.STORMTYPE || ''; const when = p.TAU != null ? (p.TAU === 0 ? 'Now' : `+${p.TAU}h`) : '';
new maplibregl.Popup().setLngLat(e.lngLat) const kt = Number(p.MAXWIND) || 0;
.setHTML(`<b>${tau}</b> ${type}<br>Winds: ${wind}${p.MSLP ? '<br>Pressure: ' + p.MSLP + ' mb' : ''}`) const cat = saffirCategory(kt);
.addTo(map); const kind = cat ? `Category ${cat} hurricane` : kt >= 34 ? 'Tropical storm' : 'Tropical depression';
}); new maplibregl.Popup().setLngLat(e.lngLat)
map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; }); .setHTML(`<b>${escHtml(when)}</b> ${escHtml(kind)}<br>Winds: ${kt ? Math.round(kt * KT_TO_MPH) + ' mph (' + kt + ' kt)' : '--'}`)
map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; }); .addTo(map);
});
map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; });
}
} }
} catch {} } catch {}
} }
// Render overlay last so storm circles sit on top of the track layers
await renderHurricaneOverlay(map);
} }
function renderPrecipChart(h, startIdx) { 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) { 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 => { ['hurricane-overlay'].forEach(id => {
if (map.getLayer(id)) map.removeLayer(id); if (map.getLayer(id)) map.removeLayer(id);
if (map.getSource(id)) map.removeSource(id); if (map.getSource(id)) map.removeSource(id);
@@ -273,17 +336,16 @@ async function renderHurricaneOverlay(map) {
if (!state.unifiedMapLayers.hurricane) return; if (!state.unifiedMapLayers.hurricane) return;
try { try {
const data = await fetch('/api/hurricanes').then(r => r.json()); const data = await fetch('/api/hurricanes').then(r => r.json());
if (tok !== map.__hurrTok) return;
const storms = data.storms || []; const storms = data.storms || [];
if (!storms.length) return; if (!storms.length) return;
const features = storms.map(s => { const features = storms.map(s => {
const lat = parseNhcCoord(s.latitude); const i = stormInfo(s);
const lon = parseNhcCoord(s.longitude); if (isNaN(i.lat) || isNaN(i.lon)) return null;
if (isNaN(lat) || isNaN(lon)) return null;
const cat = (s.classification || '').toUpperCase();
return { return {
type: 'Feature', type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] }, geometry: { type: 'Point', coordinates: [i.lon, i.lat] },
properties: { name: s.name || 'Storm', cat, intensity: s.intensity || '--' }, properties: { title: i.title, label: i.label, mph: i.mph, kt: i.kt, color: i.color, movement: i.movement },
}; };
}).filter(Boolean); }).filter(Boolean);
map.addSource('hurricane-overlay', { type: 'geojson', data: { type: 'FeatureCollection', features } }); 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', id: 'hurricane-overlay', type: 'circle', source: 'hurricane-overlay',
paint: { paint: {
'circle-radius': 14, 'circle-radius': 14,
'circle-color': ['match', ['get', 'cat'], 'HU', '#f44336', 'TS', '#9c27b0', '#2196f3'], 'circle-color': ['get', 'color'],
'circle-opacity': 0.55, 'circle-opacity': 0.55,
'circle-stroke-width': 2.5, '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 => { // Handlers are registered once; this overlay is rebuilt on every map refresh
const p = e.features[0].properties; if (!map.__stormOverlayHandlers) {
const label = p.cat === 'HU' ? 'Hurricane' : p.cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression'; map.__stormOverlayHandlers = true;
new maplibregl.Popup().setLngLat(e.lngLat) map.on('click', 'hurricane-overlay', e => {
.setHTML(`<b>${p.name}</b><br>${label}<br>Winds: ${p.intensity} mph`).addTo(map); const p = e.features[0].properties;
}); new maplibregl.Popup().setLngLat(e.lngLat)
map.on('mouseenter', 'hurricane-overlay', () => { map.getCanvas().style.cursor = 'pointer'; }); .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('mouseleave', 'hurricane-overlay', () => { map.getCanvas().style.cursor = ''; }); });
map.on('mouseenter', 'hurricane-overlay', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'hurricane-overlay', () => { map.getCanvas().style.cursor = ''; });
}
} catch {} } catch {}
} }
async function renderWildfireOverlay(map) { async function renderWildfireOverlay(map) {
if (!map || !map.loaded()) return; if (!map) return;
await mapReady(map);
['wildfire-fill', 'wildfire-outline'].forEach(id => { ['wildfire-fill', 'wildfire-outline'].forEach(id => {
if (map.getLayer(id)) map.removeLayer(id); if (map.getLayer(id)) map.removeLayer(id);
}); });
@@ -334,7 +400,8 @@ async function renderWildfireOverlay(map) {
} }
async function renderSPCOverlay(map) { async function renderSPCOverlay(map) {
if (!map || !map.loaded()) return; if (!map) return;
await mapReady(map);
['spc-overlay'].forEach(id => { ['spc-overlay'].forEach(id => {
if (map.getLayer(id)) map.removeLayer(id); if (map.getLayer(id)) map.removeLayer(id);
if (map.getSource(id)) map.removeSource(id); if (map.getSource(id)) map.removeSource(id);
@@ -1693,6 +1760,10 @@ function severityRank(severity) {
function plainAlertAction(eventName, severity) { function plainAlertAction(eventName, severity) {
const eventText = String(eventName || '').toLowerCase(); const eventText = String(eventName || '').toLowerCase();
const sev = normalizeSeverity(severity); 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('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('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.'; 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) { function buildAlertTiming(p) {
const start = p?.onset || p?.effective || p?.sent; const start = p?.onset || p?.effective || p?.sent;
const end = p?.expires; const end = p?.expires || p?.ends;
const startTxt = start ? formatClock(start) : 'now'; const fmt = iso => new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
const endTxt = end ? formatClock(end) : 'until further notice'; return `${start ? fmt(start) : 'now'} → ${end ? fmt(end) : 'until further notice'}`;
return `${startTxt} - ${endTxt}`;
} }
function buildStormEtaText() { function buildStormEtaText() {
@@ -1902,13 +1972,60 @@ function showShareWeatherStatus(msg) {
}, 2200); }, 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) { function renderAlerts(data) {
// Populate hurricane panel asynchronously // Populate hurricane panel asynchronously
fetch('/api/hurricanes').then(r => r.json()).then(d => renderHurricanePanel(d.storms || [])).catch(() => {}); fetch('/api/hurricanes').then(r => r.json()).then(d => renderHurricanePanel(d.storms || [])).catch(() => {});
const wrap = document.getElementById('alerts-wrap'); const wrap = document.getElementById('alerts-wrap');
const list = document.getElementById('alerts-list'); 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 = ''; list.innerHTML = '';
if (!features.length) { if (!features.length) {
@@ -1947,39 +2064,7 @@ function renderAlerts(data) {
head.appendChild(hint); head.appendChild(hint);
groupWrap.appendChild(head); groupWrap.appendChild(head);
grouped.get(severity).slice(0, 6).forEach(f => { grouped.get(severity).forEach(f => groupWrap.appendChild(buildAlertCard(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);
});
list.appendChild(groupWrap); list.appendChild(groupWrap);
}); });
+7 -66
View File
@@ -17,6 +17,7 @@
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
<link rel="stylesheet" href="/dashboard-ui.css"> <link rel="stylesheet" href="/dashboard-ui.css">
<script src="/alert-info.js"></script>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
@@ -1644,72 +1645,9 @@ const TAG_LABELS = {
let alertHistoryDays = 30; let alertHistoryDays = 30;
// Plain-language meaning of common NWS products, so the history says what an alert is *for* // Plain-language helpers are shared with the weather app (alert-info.js)
const ALERT_GLOSSARY = { const { meaning: alertMeaning, firstSentence: ahFirstSentence, area: ahArea, when: ahWhen } = window.AlertInfo;
'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.',
};
function alertMeaning(event) {
const e = String(event || '').toLowerCase().trim();
if (ALERT_GLOSSARY[e]) return ALERT_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 '';
}
const ahState = { items: [], open: new Set(), loading: new Set(), gone: new Set(), queue: [], running: false }; const ahState = { items: [], open: new Set(), loading: new Set(), gone: new Set(), queue: [], running: false };
const ahWhen = (iso) => iso ? new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
const ahFirstSentence = (t) => {
// NWS text is often structured "* WHAT...", "* WHERE...", "* WHEN..."; lead with the WHAT part
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 > 220 ? r.slice(0, 217) + '…' : r;
};
const ahArea = (a) => String(a || '').replace(/;\s*/g, ', ');
const SEV_ORDER = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' }; const SEV_ORDER = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' };
function ahRow(it, i) { function ahRow(it, i) {
@@ -1721,7 +1659,10 @@ function ahRow(it, i) {
let what, meta = []; let what, meta = [];
if (isNws) { if (isNws) {
what = it.description ? ahFirstSentence(it.description) : alertMeaning(it.event) || it.headline || ''; what = it.description ? ahFirstSentence(it.description) : alertMeaning(it.event) || it.headline || '';
if (it.areaDesc) meta.push(`📍 ${esc(ahArea(it.areaDesc))}`); if (it.areaDesc) {
const parts = String(it.areaDesc).split(';').map(x => x.trim()).filter(Boolean);
meta.push(`📍 ${esc(parts.length > 4 ? parts.slice(0, 4).join(', ') + ' +' + (parts.length - 4) + ' more' : ahArea(it.areaDesc))}`);
}
else if (ahState.loading.has(it.alertId)) meta.push('📍 looking up area…'); else if (ahState.loading.has(it.alertId)) meta.push('📍 looking up area…');
if (it.severity && it.severity !== 'Unknown') meta.push(`<span class="ah-sev ${SEV_ORDER[it.severity] || ''}">${esc(it.severity)}</span>`); if (it.severity && it.severity !== 'Unknown') meta.push(`<span class="ah-sev ${SEV_ORDER[it.severity] || ''}">${esc(it.severity)}</span>`);
if (it.effective || it.expires) meta.push(`🕑 ${esc(ahWhen(it.effective))}${it.expires ? ' → ' + esc(ahWhen(it.expires)) : ''}`); if (it.effective || it.expires) meta.push(`🕑 ${esc(ahWhen(it.effective))}${it.expires ? ' → ' + esc(ahWhen(it.expires)) : ''}`);
+1
View File
@@ -1295,6 +1295,7 @@
</dialog> </dialog>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script> <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script src="alert-info.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
<script src="station-live.js"></script> <script src="station-live.js"></script>
<script src="app-ui.js"></script> <script src="app-ui.js"></script>
+8 -7
View File
@@ -2429,13 +2429,14 @@ body.map-fs #unified-map {
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
} }
.cat-td { background: #2196f3; } .cat-td { background: #5dade2; }
.cat-ts { background: #9c27b0; } .cat-ts { background: #27ae60; }
.cat-1 { background: #ff9800; } .cat-1 { background: #f1c40f; color: #1a1a1a; }
.cat-2 { background: #f44336; } .cat-2 { background: #f39c12; }
.cat-3 { background: #b71c1c; } .cat-3 { background: #e74c3c; }
.cat-4 { background: #7b1fa2; } .cat-4 { background: #c0392b; }
.cat-5 { background: #1a237e; } .cat-5 { background: #9b59b6; }
.cat-ex { background: #95a5a6; }
.hurricane-stats { .hurricane-stats {
display: flex; display: flex;
+2 -1
View File
@@ -4,7 +4,7 @@ self.addEventListener('message', event => {
if (event.data === 'skipWaiting') self.skipWaiting(); if (event.data === 'skipWaiting') self.skipWaiting();
}); });
const CACHE = 'weather-v11'; const CACHE = 'weather-v12';
// App shell cached on install for offline resilience. // App shell cached on install for offline resilience.
const PRECACHE = [ const PRECACHE = [
@@ -15,6 +15,7 @@ const PRECACHE = [
'/station-live.js', '/station-live.js',
'/app-ui.css', '/app-ui.css',
'/pwa-safe.css', '/pwa-safe.css',
'/alert-info.js',
'/app-ui.js', '/app-ui.js',
'/manifest.json', '/manifest.json',
'/icons/icon.svg' '/icons/icon.svg'
+12 -5
View File
@@ -831,7 +831,7 @@ app.get('/api/alerts', async (req, res) => {
return res.status(400).json({ error: 'Invalid lat/lon' }); return res.status(400).json({ error: 'Invalid lat/lon' });
} }
try { try {
const r = await fetchWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { const r = await fetchWithRetry(`${NWS_BASE}/alerts/active?point=${lat},${lon}`, {
attempts: 1, attempts: 1,
timeoutMs: 12000, timeoutMs: 12000,
fetchOptions: { fetchOptions: {
@@ -928,10 +928,11 @@ app.post('/api/push-unsubscribe', (req, res) => {
// ── Hurricane tracking proxy (NHC) ──────────────────────────────────────────── // ── Hurricane tracking proxy (NHC) ────────────────────────────────────────────
const hCache = { data: null, at: 0 }; const hCache = { data: null, at: 0 };
const NHC_BASE = process.env.NHC_BASE || 'https://www.nhc.noaa.gov';
app.get('/api/hurricanes', async (_req, res) => { app.get('/api/hurricanes', async (_req, res) => {
if (hCache.data && Date.now() - hCache.at < 5 * 60 * 1000) return res.json(hCache.data); if (hCache.data && Date.now() - hCache.at < 5 * 60 * 1000) return res.json(hCache.data);
try { try {
const r = await fetchWithRetry('https://www.nhc.noaa.gov/CurrentStorms.json', { const r = await fetchWithRetry(`${NHC_BASE}/CurrentStorms.json`, {
attempts: 2, timeoutMs: 10000, attempts: 2, timeoutMs: 10000,
fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/json' } }, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/json' } },
}); });
@@ -945,6 +946,11 @@ app.get('/api/hurricanes', async (_req, res) => {
} }
}); });
// Strength class for a point on the forecast track (falls back to the storm's own class when wind is unknown)
function typeForKt(kt, fallback) {
if (!kt) return fallback || 'TS';
return kt >= 64 ? 'HU' : kt >= 34 ? 'TS' : 'TD';
}
app.get('/api/hurricanes/track', async (req, res) => { app.get('/api/hurricanes/track', async (req, res) => {
const { stormId } = req.query; const { stormId } = req.query;
if (!stormId || !/^[a-z]{2}\d{6}$/i.test(stormId)) { if (!stormId || !/^[a-z]{2}\d{6}$/i.test(stormId)) {
@@ -954,7 +960,7 @@ app.get('/api/hurricanes/track', async (req, res) => {
const sid = stormId.toLowerCase(); const sid = stormId.toLowerCase();
let storm = (hCache.data?.storms || []).find(s => s.id?.toLowerCase() === sid); let storm = (hCache.data?.storms || []).find(s => s.id?.toLowerCase() === sid);
if (!storm) { if (!storm) {
const r = await fetchWithRetry('https://www.nhc.noaa.gov/CurrentStorms.json', { const r = await fetchWithRetry(`${NHC_BASE}/CurrentStorms.json`, {
attempts: 2, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)' } } attempts: 2, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)' } }
}); });
storm = ((await r.json()).activeStorms || []).find(s => s.id?.toLowerCase() === sid); storm = ((await r.json()).activeStorms || []).find(s => s.id?.toLowerCase() === sid);
@@ -992,7 +998,7 @@ app.get('/api/hurricanes/track', async (req, res) => {
lat: parseFloat(initM[1]) * (initM[2] === 'S' ? -1 : 1), lat: parseFloat(initM[1]) * (initM[2] === 'S' ? -1 : 1),
lon: parseFloat(initM[3]) * (initM[4] === 'W' ? -1 : 1), lon: parseFloat(initM[3]) * (initM[4] === 'W' ? -1 : 1),
windKt: curWindM ? parseInt(curWindM[1]) : 0, windKt: curWindM ? parseInt(curWindM[1]) : 0,
type: storm?.classification || 'TS', type: typeForKt(curWindM ? parseInt(curWindM[1]) : 0, storm?.classification),
}); });
} }
@@ -1004,7 +1010,8 @@ app.get('/api/hurricanes/track', async (req, res) => {
const lat = parseFloat(m[3]) * (m[4] === 'S' ? -1 : 1); const lat = parseFloat(m[3]) * (m[4] === 'S' ? -1 : 1);
const lon = parseFloat(m[5]) * (m[6] === 'W' ? -1 : 1); const lon = parseFloat(m[5]) * (m[6] === 'W' ? -1 : 1);
const windM = text.substring(m.index).match(/MAX WIND\s+(\d+)\s+KT/); const windM = text.substring(m.index).match(/MAX WIND\s+(\d+)\s+KT/);
positions.push({ tau, lat, lon, windKt: windM ? parseInt(windM[1]) : 0, type: storm?.classification || 'TS' }); const kt = windM ? parseInt(windM[1]) : 0;
positions.push({ tau, lat, lon, windKt: kt, type: typeForKt(kt, storm?.classification) });
} }
if (!positions.length) return res.json({ lin: null, pts: null, pgn: null }); if (!positions.length) return res.json({ lin: null, pts: null, pgn: null });