Fix map basemap tiles and add hurricane click-to-map feature
Publish Container / publish (push) Successful in 2m18s
Publish Container / publish (push) Successful in 2m18s
- Replace CARTO basemap tiles (now require API key) with OpenFreeMap vector tiles (positron for light, liberty for dark) — no key needed - Add click handlers on hurricane panel cards to fly to storm on map - Add flyToHurricane(): switches to map tab, enables hurricane overlay, flies to storm coords, shows popup with storm details - Add /api/hurricanes/track proxy to fetch NHC GIS 5-day forecast track line and render it as a dashed overlay on the map Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
de9cb41d8b
commit
8b9d3639af
2 files changed
+84
-14
No files matched your search
+67
-14
@@ -60,10 +60,13 @@ function parseNhcCoord(str) {
|
|||||||
return (m[2].toUpperCase() === 'S' || m[2].toUpperCase() === 'W') ? -v : v;
|
return (m[2].toUpperCase() === 'S' || m[2].toUpperCase() === 'W') ? -v : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hurricaneStorms = [];
|
||||||
|
|
||||||
function renderHurricanePanel(storms) {
|
function renderHurricanePanel(storms) {
|
||||||
const panel = document.getElementById('hurricane-panel');
|
const panel = document.getElementById('hurricane-panel');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
if (!storms || !storms.length) { panel.innerHTML = ''; return; }
|
if (!storms || !storms.length) { panel.innerHTML = ''; return; }
|
||||||
|
hurricaneStorms = storms;
|
||||||
const catLabel = c => {
|
const catLabel = c => {
|
||||||
if (!c) return '';
|
if (!c) return '';
|
||||||
const up = c.toUpperCase();
|
const up = c.toUpperCase();
|
||||||
@@ -86,7 +89,7 @@ function renderHurricanePanel(storms) {
|
|||||||
const lat = parseNhcCoord(s.latitude);
|
const lat = parseNhcCoord(s.latitude);
|
||||||
const lon = parseNhcCoord(s.longitude);
|
const lon = parseNhcCoord(s.longitude);
|
||||||
return `
|
return `
|
||||||
<div class="hurricane-storm-card">
|
<div class="hurricane-storm-card" style="cursor:pointer" title="Click to view on map">
|
||||||
<div class="hurricane-header">
|
<div class="hurricane-header">
|
||||||
<span class="hurricane-name">${s.name || 'Storm'}</span>
|
<span class="hurricane-name">${s.name || 'Storm'}</span>
|
||||||
<span class="hurricane-cat-badge ${catLabel(s.classification)}">${catName(s.classification)}</span>
|
<span class="hurricane-cat-badge ${catLabel(s.classification)}">${catName(s.classification)}</span>
|
||||||
@@ -96,9 +99,61 @@ function renderHurricanePanel(storms) {
|
|||||||
${s.pressure ? `<span>Pressure: ${s.pressure} mb</span>` : ''}
|
${s.pressure ? `<span>Pressure: ${s.pressure} mb</span>` : ''}
|
||||||
<span>${isNaN(lat) ? s.latitude : lat.toFixed(1)+'°'}, ${isNaN(lon) ? s.longitude : Math.abs(lon).toFixed(1)+'°'+(lon < 0 ? 'W' : 'E')}</span>
|
<span>${isNaN(lat) ? s.latitude : lat.toFixed(1)+'°'}, ${isNaN(lon) ? s.longitude : Math.abs(lon).toFixed(1)+'°'+(lon < 0 ? 'W' : 'E')}</span>
|
||||||
${s.movement ? `<span>Moving ${s.movement}</span>` : ''}
|
${s.movement ? `<span>Moving ${s.movement}</span>` : ''}
|
||||||
|
<span style="color:var(--accent,#4da6ff);font-size:0.75em">🗺 View on map</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`}).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(`<b>${storm.name || 'Storm'}</b><br>${label}<br>Winds: ${storm.intensity || '--'} mph${storm.movement ? '<br>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) {
|
function renderPrecipChart(h, startIdx) {
|
||||||
@@ -771,19 +826,17 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|||||||
const baseMapType = mapEl?.id === 'unified-map' ? state.unifiedMapBaseLayer : 'dark';
|
const baseMapType = mapEl?.id === 'unified-map' ? state.unifiedMapBaseLayer : 'dark';
|
||||||
|
|
||||||
function buildStyle(type) {
|
function buildStyle(type) {
|
||||||
const tileUrl = type === 'light'
|
if (type === 'satellite') {
|
||||||
? 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'
|
return {
|
||||||
: type === 'satellite'
|
version: 8,
|
||||||
? 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
sources: { base: { type: 'raster', tileSize: 256, attribution: '© Esri',
|
||||||
: 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
|
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'] } },
|
||||||
return {
|
layers: [{ id: 'base', type: 'raster', source: 'base' }],
|
||||||
version: 8,
|
};
|
||||||
sources: {
|
}
|
||||||
base: { type: 'raster', tiles: [tileUrl], tileSize: 256,
|
return type === 'light'
|
||||||
attribution: '© OpenStreetMap contributors, © CARTO' }
|
? 'https://tiles.openfreemap.org/styles/positron'
|
||||||
},
|
: 'https://tiles.openfreemap.org/styles/liberty';
|
||||||
layers: [{ id: 'base', type: 'raster', source: 'base' }],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
|
|||||||
@@ -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) ───────────────────────────────────────────
|
// ── Wildfire perimeter proxy (NIFC) ───────────────────────────────────────────
|
||||||
const wfCache = { data: null, at: 0 };
|
const wfCache = { data: null, at: 0 };
|
||||||
app.get('/api/wildfires', async (_req, res) => {
|
app.get('/api/wildfires', async (_req, res) => {
|
||||||
|
|||||||
Reference in new issue
Block a user