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 ` -
+
${s.name || 'Storm'} ${catName(s.classification)} @@ -96,9 +99,61 @@ function renderHurricanePanel(storms) { ${s.pressure ? `Pressure: ${s.pressure} mb` : ''} ${isNaN(lat) ? s.latitude : lat.toFixed(1)+'°'}, ${isNaN(lon) ? s.longitude : Math.abs(lon).toFixed(1)+'°'+(lon < 0 ? 'W' : 'E')} ${s.movement ? `Moving ${s.movement}` : ''} + 🗺 View on map
`}).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) => {