From 3af4c1df4bf3941a9f9094006de5a57bdf5f2cee Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Wed, 2 Sep 2026 10:11:22 -0400 Subject: [PATCH] Add NHC forecast track to hurricane click-to-map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/public/app.js | 67 +++++++++++++++++++++++++++++++------- app/server.js | 83 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 130 insertions(+), 20 deletions(-) diff --git a/app/public/app.js b/app/public/app.js index 927cf9b..b667fbf 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -127,7 +127,7 @@ async function flyToHurricane(storm) { if (!map) return; await renderHurricaneOverlay(map); - map.flyTo({ center: [lon, lat], zoom: 5, duration: 900 }); + map.flyTo({ center: [lon, lat], zoom: 4, duration: 900 }); setTimeout(() => { const cat = (storm.classification || '').toUpperCase(); @@ -140,17 +140,62 @@ async function flyToHurricane(storm) { if (storm.id) { try { - const trackData = await fetch(`/api/hurricanes/track?stormId=${encodeURIComponent(storm.id)}`).then(r => r.json()); - if (trackData?.type === 'FeatureCollection' && trackData.features?.length) { - ['hurricane-track'].forEach(id => { - if (map.getLayer(id)) map.removeLayer(id); - if (map.getSource(id)) map.removeSource(id); - }); - map.addSource('hurricane-track', { type: 'geojson', data: trackData }); - map.addLayer({ - id: 'hurricane-track', type: 'line', source: 'hurricane-track', - paint: { 'line-color': '#ff5722', 'line-width': 2.5, 'line-dasharray': [4, 2] }, + const { lin, pts, pgn } = await fetch(`/api/hurricanes/track?stormId=${encodeURIComponent(storm.id)}`).then(r => r.json()); + + ['hurricane-track-fill', 'hurricane-track-line', 'hurricane-track-pts'].forEach(id => { + if (map.getLayer(id)) map.removeLayer(id); + }); + ['hurricane-track-pgn', 'hurricane-track-lin', 'hurricane-track-pts-src'].forEach(id => { + if (map.getSource(id)) map.removeSource(id); + }); + + if (pgn?.type === 'FeatureCollection' && pgn.features?.length) { + map.addSource('hurricane-track-pgn', { type: 'geojson', data: pgn }); + map.addLayer({ id: 'hurricane-track-fill', type: 'fill', source: 'hurricane-track-pgn', + paint: { 'fill-color': '#ff5722', 'fill-opacity': 0.12 } }); + } + + 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] } }); + } + + 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', + paint: { + 'circle-radius': 7, + 'circle-color': ['match', ['get', 'TCDVLP'], + 'Hurricane', '#f44336', 'HU', '#f44336', + 'Tropical Storm', '#9c27b0', 'TS', '#9c27b0', + '#2196f3'], + 'circle-stroke-width': 2, 'circle-stroke-color': '#fff', + } }); + + // Fit map to show full track + const allCoords = pts.features.map(f => f.geometry.coordinates); + if (allCoords.length > 1) { + const lons = allCoords.map(c => c[0]); + const lats = allCoords.map(c => c[1]); + map.fitBounds( + [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]], + { padding: 60, duration: 600, maxZoom: 5 } + ); + } + + 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 = ''; }); } } catch {} } diff --git a/app/server.js b/app/server.js index 5538d9b..05ba7d7 100644 --- a/app/server.js +++ b/app/server.js @@ -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 }); } });