Add NHC forecast track to hurricane click-to-map
Publish Container / publish (push) Successful in 2m6s
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:
1 parent
8b9d3639af
commit
3af4c1df4b
2 files changed
+130
-20
No files matched your search
+56
-11
@@ -127,7 +127,7 @@ async function flyToHurricane(storm) {
|
|||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
await renderHurricaneOverlay(map);
|
await renderHurricaneOverlay(map);
|
||||||
map.flyTo({ center: [lon, lat], zoom: 5, duration: 900 });
|
map.flyTo({ center: [lon, lat], zoom: 4, duration: 900 });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const cat = (storm.classification || '').toUpperCase();
|
const cat = (storm.classification || '').toUpperCase();
|
||||||
@@ -140,17 +140,62 @@ async function flyToHurricane(storm) {
|
|||||||
|
|
||||||
if (storm.id) {
|
if (storm.id) {
|
||||||
try {
|
try {
|
||||||
const trackData = await fetch(`/api/hurricanes/track?stormId=${encodeURIComponent(storm.id)}`).then(r => r.json());
|
const { lin, pts, pgn } = await fetch(`/api/hurricanes/track?stormId=${encodeURIComponent(storm.id)}`).then(r => r.json());
|
||||||
if (trackData?.type === 'FeatureCollection' && trackData.features?.length) {
|
|
||||||
['hurricane-track'].forEach(id => {
|
['hurricane-track-fill', 'hurricane-track-line', 'hurricane-track-pts'].forEach(id => {
|
||||||
if (map.getLayer(id)) map.removeLayer(id);
|
if (map.getLayer(id)) map.removeLayer(id);
|
||||||
if (map.getSource(id)) map.removeSource(id);
|
});
|
||||||
});
|
['hurricane-track-pgn', 'hurricane-track-lin', 'hurricane-track-pts-src'].forEach(id => {
|
||||||
map.addSource('hurricane-track', { type: 'geojson', data: trackData });
|
if (map.getSource(id)) map.removeSource(id);
|
||||||
map.addLayer({
|
});
|
||||||
id: 'hurricane-track', type: 'line', source: 'hurricane-track',
|
|
||||||
paint: { 'line-color': '#ff5722', 'line-width': 2.5, 'line-dasharray': [4, 2] },
|
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(`<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 = ''; });
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-9
@@ -514,17 +514,82 @@ app.get('/api/hurricanes', async (_req, res) => {
|
|||||||
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)) {
|
||||||
return res.json({ type: 'FeatureCollection', features: [] });
|
return res.json({ lin: null, pts: null, pgn: null });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const id = stormId.toUpperCase();
|
const sid = stormId.toLowerCase();
|
||||||
const r = await fetchWithRetry(
|
let storm = (hCache.data?.storms || []).find(s => s.id?.toLowerCase() === sid);
|
||||||
`https://www.nhc.noaa.gov/gis/json/${id}_5day_lin.json`,
|
if (!storm) {
|
||||||
{ attempts: 2, timeoutMs: 8000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/json' } } }
|
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)' } }
|
||||||
res.json(await r.json());
|
});
|
||||||
} catch {
|
storm = ((await r.json()).activeStorms || []).find(s => s.id?.toLowerCase() === sid);
|
||||||
res.json({ type: 'FeatureCollection', features: [] });
|
}
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user