Add NHC forecast track to hurricane click-to-map
Publish Container / publish (push) Successful in 2m6s

- Parse NHC forecast advisory text (FORECAST VALID / OUTLOOK VALID lines)
  to extract real forecast positions at 9–117h intervals with wind speeds
- Render forecast track as dashed line + clickable dots (wind on click)
- Auto-fit map bounds to show the full 5-day track after loading
- Fix buildStyle to use OpenFreeMap vector tile URLs without API key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 4.6 committed 2026-09-02 10:11:22 -04:00
1 parent 8b9d3639af
commit 3af4c1df4b
2 files changed
+130 -20

No files matched your search

+74 -9
View File
@@ -514,17 +514,82 @@ app.get('/api/hurricanes', async (_req, res) => {
app.get('/api/hurricanes/track', async (req, res) => {
const { stormId } = req.query;
if (!stormId || !/^[a-z]{2}\d{6}$/i.test(stormId)) {
return res.json({ type: 'FeatureCollection', features: [] });
return res.json({ lin: null, pts: null, pgn: null });
}
try {
const id = stormId.toUpperCase();
const r = await fetchWithRetry(
`https://www.nhc.noaa.gov/gis/json/${id}_5day_lin.json`,
{ attempts: 2, timeoutMs: 8000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/json' } } }
);
res.json(await r.json());
} catch {
res.json({ type: 'FeatureCollection', features: [] });
const sid = stormId.toLowerCase();
let storm = (hCache.data?.storms || []).find(s => s.id?.toLowerCase() === sid);
if (!storm) {
const r = await fetchWithRetry('https://www.nhc.noaa.gov/CurrentStorms.json', {
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);
}
const advisoryUrl = storm?.forecastAdvisory?.url;
if (!advisoryUrl) return res.json({ lin: null, pts: null, pgn: null });
const r = await fetchWithRetry(advisoryUrl, {
attempts: 2, timeoutMs: 8000,
fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)' } }
});
const text = (await r.text()).replace(/<[^>]+>/g, '');
const issuanceMs = new Date(storm?.forecastAdvisory?.issuance || storm?.lastUpdate || Date.now()).getTime();
const issueDate = new Date(issuanceMs);
function parseDDHHZ(ddHH) {
const day = parseInt(ddHH.slice(0, 2));
const hour = parseInt(ddHH.slice(2, 4));
const d = new Date(issueDate);
d.setUTCDate(day);
d.setUTCHours(hour, 0, 0, 0);
if (d < issueDate) d.setUTCMonth(d.getUTCMonth() + 1);
return Math.round((d - issueDate) / 3600000);
}
const positions = [];
// Current position from "CENTER LOCATED NEAR LL.LN LLL.LW AT DD/HHMMZ"
const initM = text.match(/CENTER LOCATED NEAR\s+(\d+\.?\d*)([NS])\s+(\d+\.?\d*)([EW])/);
const curWindM = text.match(/MAX SUSTAINED WINDS\s+(\d+)\s+KT/);
if (initM) {
positions.push({
tau: 0,
lat: parseFloat(initM[1]) * (initM[2] === 'S' ? -1 : 1),
lon: parseFloat(initM[3]) * (initM[4] === 'W' ? -1 : 1),
windKt: curWindM ? parseInt(curWindM[1]) : 0,
type: storm?.classification || 'TS',
});
}
// "FORECAST VALID DD/HHMMZ LL.LN LLL.LW" and "OUTLOOK VALID ..." lines
const fcstRe = /(?:FORECAST|OUTLOOK)\s+VALID\s+(\d{2})\/(\d{2})\d{2}Z\s+(\d+\.?\d*)([NS])\s+(\d+\.?\d*)([EW])/g;
let m;
while ((m = fcstRe.exec(text)) !== null) {
const tau = parseDDHHZ(m[1] + m[2]);
const lat = parseFloat(m[3]) * (m[4] === 'S' ? -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/);
positions.push({ tau, lat, lon, windKt: windM ? parseInt(windM[1]) : 0, type: storm?.classification || 'TS' });
}
if (!positions.length) return res.json({ lin: null, pts: null, pgn: null });
const coords = positions.map(p => [p.lon, p.lat]);
const ptFeatures = positions.map(p => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
properties: { TAU: p.tau, MAXWIND: p.windKt, TCDVLP: p.type },
}));
res.json({
lin: { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} }] },
pts: { type: 'FeatureCollection', features: ptFeatures },
pgn: null,
});
} catch (e) {
console.error('[track] error:', e.message);
res.json({ lin: null, pts: null, pgn: null });
}
});