diff --git a/app/public/app.js b/app/public/app.js
index 995d2f7..927cf9b 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -60,10 +60,13 @@ function parseNhcCoord(str) {
return (m[2].toUpperCase() === 'S' || m[2].toUpperCase() === 'W') ? -v : v;
}
+let hurricaneStorms = [];
+
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();
@@ -86,7 +89,7 @@ function renderHurricanePanel(storms) {
const lat = parseNhcCoord(s.latitude);
const lon = parseNhcCoord(s.longitude);
return `
-
+
`}).join('');
+ panel.querySelectorAll('.hurricane-storm-card').forEach((card, i) => {
+ card.addEventListener('click', () => flyToHurricane(hurricaneStorms[i]));
+ });
+}
+
+async function flyToHurricane(storm) {
+ const lat = parseNhcCoord(storm.latitude);
+ const lon = parseNhcCoord(storm.longitude);
+ if (isNaN(lat) || isNaN(lon)) return;
+
+ activateTab('unified-map');
+
+ if (!state.unifiedMapLayers.hurricane) {
+ state.unifiedMapLayers.hurricane = true;
+ const cb = document.querySelector('#tab-unified-map input[data-layer="hurricane"]');
+ if (cb) cb.checked = true;
+ }
+
+ await new Promise(r => setTimeout(r, 450));
+
+ const map = mapsOverview.map;
+ if (!map) return;
+
+ await renderHurricaneOverlay(map);
+ map.flyTo({ center: [lon, lat], zoom: 5, duration: 900 });
+
+ setTimeout(() => {
+ const cat = (storm.classification || '').toUpperCase();
+ const label = cat === 'HU' ? 'Hurricane' : cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression';
+ new maplibregl.Popup({ closeButton: true, maxWidth: '240px' })
+ .setLngLat([lon, lat])
+ .setHTML(`
${storm.name || 'Storm'}${label}
Winds: ${storm.intensity || '--'} mph${storm.movement ? '
Moving ' + storm.movement : ''}`)
+ .addTo(map);
+ }, 950);
+
+ 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] },
+ });
+ }
+ } catch {}
+ }
}
function renderPrecipChart(h, startIdx) {
@@ -771,19 +826,17 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
const baseMapType = mapEl?.id === 'unified-map' ? state.unifiedMapBaseLayer : 'dark';
function buildStyle(type) {
- const tileUrl = type === 'light'
- ? 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'
- : type === 'satellite'
- ? 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
- : 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
- return {
- version: 8,
- sources: {
- base: { type: 'raster', tiles: [tileUrl], tileSize: 256,
- attribution: '© OpenStreetMap contributors, © CARTO' }
- },
- layers: [{ id: 'base', type: 'raster', source: 'base' }],
- };
+ if (type === 'satellite') {
+ return {
+ version: 8,
+ sources: { base: { type: 'raster', tileSize: 256, attribution: '© Esri',
+ tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'] } },
+ layers: [{ id: 'base', type: 'raster', source: 'base' }],
+ };
+ }
+ return type === 'light'
+ ? 'https://tiles.openfreemap.org/styles/positron'
+ : 'https://tiles.openfreemap.org/styles/liberty';
}
const map = new maplibregl.Map({
diff --git a/app/server.js b/app/server.js
index 3789e56..5538d9b 100644
--- a/app/server.js
+++ b/app/server.js
@@ -511,6 +511,23 @@ 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: [] });
+ }
+ 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: [] });
+ }
+});
+
// ── Wildfire perimeter proxy (NIFC) ───────────────────────────────────────────
const wfCache = { data: null, at: 0 };
app.get('/api/wildfires', async (_req, res) => {