perf: faster map overlay load #4
1 changed files with 94 additions and 30 deletions
|
|
@ -1650,6 +1650,16 @@ let vesselsGroup = null, vesselsOn = false;
|
|||
let stormsGroup = null, stormsOn = true;
|
||||
let overlayReq = {ac:0, trains:0, vessels:0, alerts:0, perim:0, incidents:0, storms:0};
|
||||
let moveDebounce = null;
|
||||
let overlayAbort = null;
|
||||
let lastCell = '';
|
||||
function bboxCell() {
|
||||
if (!map) return '';
|
||||
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
|
||||
}
|
||||
function overlayFetch(url) {
|
||||
return fetch(url, overlayAbort ? { signal: overlayAbort.signal } : {});
|
||||
}
|
||||
function isAbort(e) { return e && e.name === 'AbortError'; }
|
||||
const pointCanvas = () => L.canvas({ padding: 0.5 });
|
||||
|
||||
async function initMap() {
|
||||
|
|
@ -1733,6 +1743,11 @@ async function initMap() {
|
|||
if (camPopupOpen) return; // only the popup's own autopan now
|
||||
if (moveDebounce) clearTimeout(moveDebounce);
|
||||
moveDebounce = setTimeout(() => {
|
||||
const cell = bboxCell();
|
||||
if (cell === lastCell) return;
|
||||
lastCell = cell;
|
||||
if (overlayAbort) overlayAbort.abort();
|
||||
overlayAbort = new AbortController();
|
||||
if (firesOn) loadFires();
|
||||
if (camsOn) loadCams();
|
||||
if (blipsOn) loadBlips();
|
||||
|
|
@ -1977,7 +1992,7 @@ async function loadFires() {
|
|||
let url = `${API}/api/fires?bbox=${currentBBox()}&limit=2000&format=heat`;
|
||||
const since = sinceToISO(firesSince);
|
||||
if (since) url += `&since=${encodeURIComponent(since)}`;
|
||||
const r = await fetch(url);
|
||||
const r = await overlayFetch(url);
|
||||
const fires = await r.json();
|
||||
if (req !== fireReq) return; // superseded by a newer pan/zoom
|
||||
if (firesHeat) map.removeLayer(firesHeat);
|
||||
|
|
@ -1994,6 +2009,7 @@ async function loadFires() {
|
|||
`${fires.length.toLocaleString()} fire hotspots in view` +
|
||||
(since ? ` · since ${since.slice(0,16).replace('T',' ')}Z` : '');
|
||||
} catch(e) {
|
||||
if (isAbort(e)) return;
|
||||
document.getElementById('map-hint').textContent = `Fires load failed: ${e.message || e}`;
|
||||
console.error('Fires load failed', e);
|
||||
}
|
||||
|
|
@ -2068,7 +2084,7 @@ async function loadCams() {
|
|||
}
|
||||
const req = ++camReq;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`);
|
||||
const r = await overlayFetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`);
|
||||
const cams = await r.json();
|
||||
if (req !== camReq) return; // superseded by a newer pan/zoom
|
||||
if (camsGroup) map.removeLayer(camsGroup);
|
||||
|
|
@ -2092,7 +2108,7 @@ async function loadCams() {
|
|||
iconSize: [12, 12], iconAnchor: [6, 6],
|
||||
});
|
||||
camsGroup.addLayer(L.marker([c.lat, c.lon], { icon })
|
||||
.bindPopup(`<div class="cam-pop">` +
|
||||
.bindPopup(() => `<div class="cam-pop">` +
|
||||
`<b>${esc(c.location_name || 'Open camera')}</b>` +
|
||||
`${c.id ? camThumb(c) : '<div class="thumb placeholder">no snapshot</div>'}` +
|
||||
`<table>` +
|
||||
|
|
@ -2113,6 +2129,7 @@ async function loadCams() {
|
|||
document.getElementById('map-hint').textContent =
|
||||
`${cams.length.toLocaleString()} open cameras in view`;
|
||||
} catch(e) {
|
||||
if (isAbort(e)) return;
|
||||
document.getElementById('map-hint').textContent = `Cameras load failed: ${e.message || e}`;
|
||||
console.error('Cameras load failed', e);
|
||||
}
|
||||
|
|
@ -2148,7 +2165,7 @@ async function loadBlips() {
|
|||
let url = `${API}/api/events?bbox=${currentBBox()}&has_coords=true&limit=500`;
|
||||
const since = sinceToISO(blipsSince);
|
||||
if (since) url += `&since=${encodeURIComponent(since)}`;
|
||||
const r = await fetch(url);
|
||||
const r = await overlayFetch(url);
|
||||
const evs = (await r.json()).filter(ev => ev.source_type !== 'camera');
|
||||
if (req !== blipReq) return; // superseded by a newer pan/zoom
|
||||
if (blipsGroup) map.removeLayer(blipsGroup);
|
||||
|
|
@ -2178,6 +2195,7 @@ async function loadBlips() {
|
|||
`${evs.length.toLocaleString()} event blips in view`;
|
||||
}
|
||||
} catch(e) {
|
||||
if (isAbort(e)) return;
|
||||
document.getElementById('map-hint').textContent = `Blips load failed: ${e.message || e}`;
|
||||
console.error('Blips load failed', e);
|
||||
}
|
||||
|
|
@ -2289,33 +2307,69 @@ function feedIcon(feed, color, heading) {
|
|||
}
|
||||
return ic;
|
||||
}
|
||||
function makePointMarker(p, colorFn, feed, renderer) {
|
||||
if (p.lat == null || p.lon == null) return null;
|
||||
const col = sanitizeColor(colorFn(p), '#35e0ff');
|
||||
if (feed) {
|
||||
const heading = Number(p.heading);
|
||||
const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading);
|
||||
return L.marker([p.lat, p.lon], { icon }).bindPopup(() => pointPopup(p));
|
||||
}
|
||||
return L.circleMarker([p.lat, p.lon], {
|
||||
radius: 5, color: col, fillColor: col, fillOpacity: 0.9, weight: 1,
|
||||
renderer,
|
||||
}).bindPopup(() => pointPopup(p));
|
||||
}
|
||||
function renderPoints(existing, points, colorFn, cluster, feed) {
|
||||
if (existing) map.removeLayer(existing);
|
||||
const zoom = map.getZoom();
|
||||
const useCluster = cluster && (zoom < 7 || points.length > 200);
|
||||
const group = useCluster
|
||||
? L.markerClusterGroup({ maxClusterRadius: 48, showCoverageOnHover: false, spiderfyOnMaxZoom: true, chunkedLoading: true })
|
||||
: L.layerGroup();
|
||||
const canReuse = existing && map.hasLayer(existing)
|
||||
&& !!existing._osintCluster === !!useCluster
|
||||
&& existing._osintById;
|
||||
if (!canReuse) {
|
||||
if (existing) map.removeLayer(existing);
|
||||
const group = useCluster
|
||||
? L.markerClusterGroup({ maxClusterRadius: 48, showCoverageOnHover: false, spiderfyOnMaxZoom: true, chunkedLoading: true })
|
||||
: L.layerGroup();
|
||||
group._osintCluster = !!useCluster;
|
||||
group._osintById = new Map();
|
||||
const renderer = pointCanvas();
|
||||
points.forEach(p => {
|
||||
const m = makePointMarker(p, colorFn, feed, renderer);
|
||||
if (!m) return;
|
||||
group.addLayer(m);
|
||||
if (p.id != null) group._osintById.set(String(p.id), m);
|
||||
});
|
||||
group.addTo(map);
|
||||
return group;
|
||||
}
|
||||
const group = existing;
|
||||
const byId = group._osintById;
|
||||
const next = new Set();
|
||||
const renderer = pointCanvas();
|
||||
points.forEach(p => {
|
||||
if (p.lat == null || p.lon == null) return;
|
||||
if (p.lat == null || p.lon == null || p.id == null) return;
|
||||
const id = String(p.id);
|
||||
next.add(id);
|
||||
const col = sanitizeColor(colorFn(p), '#35e0ff');
|
||||
if (feed) {
|
||||
const heading = Number(p.heading);
|
||||
const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading);
|
||||
const m = L.marker([p.lat, p.lon], { icon }).bindPopup(pointPopup(p));
|
||||
group.addLayer(m);
|
||||
const m = byId.get(id);
|
||||
if (m) {
|
||||
m.setLatLng([p.lat, p.lon]);
|
||||
if (feed) m.setIcon(feedIcon(feed, col, p.heading));
|
||||
else if (m.setStyle) m.setStyle({ color: col, fillColor: col });
|
||||
} else {
|
||||
const heading = Number(p.heading);
|
||||
const m = L.circleMarker([p.lat, p.lon], {
|
||||
radius: 5, color: col, fillColor: col, fillOpacity: 0.9, weight: 1,
|
||||
renderer,
|
||||
}).bindPopup(pointPopup(p));
|
||||
if (!Number.isNaN(heading)) m.setStyle({ className: 'hdg' });
|
||||
group.addLayer(m);
|
||||
const nm = makePointMarker(p, colorFn, feed, renderer);
|
||||
if (!nm) return;
|
||||
group.addLayer(nm);
|
||||
byId.set(id, nm);
|
||||
}
|
||||
});
|
||||
byId.forEach((m, id) => {
|
||||
if (!next.has(id)) {
|
||||
group.removeLayer(m);
|
||||
byId.delete(id);
|
||||
}
|
||||
});
|
||||
group.addTo(map);
|
||||
return group;
|
||||
}
|
||||
async function toggleRadar() {
|
||||
|
|
@ -2332,7 +2386,7 @@ async function loadRadar() {
|
|||
if (!map) return;
|
||||
try {
|
||||
if (!radarMeta) {
|
||||
const r = await fetch(`${API}/api/map/radar`);
|
||||
const r = await overlayFetch(`${API}/api/map/radar`);
|
||||
radarMeta = await r.json();
|
||||
addExtraAttrib('<a href="https://www.rainviewer.com/api.html">Weather data by RainViewer</a>');
|
||||
addExtraAttrib('Iowa Environmental Mesonet');
|
||||
|
|
@ -2351,6 +2405,7 @@ async function loadRadar() {
|
|||
radarLayer = L.tileLayer(url, { opacity: radarOpacity, maxZoom: 12, maxNativeZoom: useIem ? 18 : 7, attribution: '' }).addTo(map);
|
||||
}
|
||||
} catch (e) {
|
||||
if (isAbort(e)) return;
|
||||
console.error('Radar load failed', e);
|
||||
document.getElementById('lp-radar-count').textContent = 'err';
|
||||
}
|
||||
|
|
@ -2383,12 +2438,13 @@ async function loadWxAlerts() {
|
|||
}
|
||||
const req = ++overlayReq.alerts;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/weather-alerts?bbox=${currentBBox()}`);
|
||||
const r = await overlayFetch(`${API}/api/weather-alerts?bbox=${currentBBox()}`);
|
||||
const fc = await r.json();
|
||||
if (req !== overlayReq.alerts) return;
|
||||
wxAlertsGroup = dropLayer(wxAlertsGroup);
|
||||
const feats = fc.features || [];
|
||||
wxAlertsGroup = L.geoJSON(fc, {
|
||||
renderer: L.canvas({ padding: 0.5 }),
|
||||
style: (f) => ({
|
||||
color: severityColor((f.properties || {}).severity),
|
||||
weight: 2, fillOpacity: 0.18,
|
||||
|
|
@ -2401,6 +2457,7 @@ async function loadWxAlerts() {
|
|||
document.getElementById('lp-alerts-count').textContent = feats.length.toLocaleString();
|
||||
addExtraAttrib('NWS / IEM storm-based warnings');
|
||||
} catch (e) {
|
||||
if (isAbort(e)) return;
|
||||
console.error('Alerts load failed', e);
|
||||
document.getElementById('lp-alerts-count').textContent = 'err';
|
||||
}
|
||||
|
|
@ -2419,12 +2476,13 @@ async function loadPerimeters() {
|
|||
}
|
||||
const req = ++overlayReq.perim;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/fire-perimeters?bbox=${currentBBox()}`);
|
||||
const r = await overlayFetch(`${API}/api/fire-perimeters?bbox=${currentBBox()}`);
|
||||
const fc = await r.json();
|
||||
if (req !== overlayReq.perim) return;
|
||||
perimGroup = dropLayer(perimGroup);
|
||||
const feats = fc.features || [];
|
||||
perimGroup = L.geoJSON(fc, {
|
||||
renderer: L.canvas({ padding: 0.5 }),
|
||||
style: (f) => {
|
||||
const acres = Number((f.properties || {}).poly_GISAcres || (f.properties || {}).attr_IncidentSize || 0);
|
||||
return { color: acres > 10000 ? '#ef4444' : '#fb923c', weight: 2, fillOpacity: 0.25, fillColor: '#fb923c' };
|
||||
|
|
@ -2439,6 +2497,7 @@ async function loadPerimeters() {
|
|||
document.getElementById('lp-perim-count').textContent = feats.length.toLocaleString();
|
||||
addExtraAttrib('NIFC WFIGS');
|
||||
} catch (e) {
|
||||
if (isAbort(e)) return;
|
||||
console.error('Perimeters load failed', e);
|
||||
document.getElementById('lp-perim-count').textContent = 'err';
|
||||
}
|
||||
|
|
@ -2457,13 +2516,14 @@ async function loadIncidents() {
|
|||
}
|
||||
const req = ++overlayReq.incidents;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/fire-incidents?bbox=${currentBBox()}`);
|
||||
const r = await overlayFetch(`${API}/api/fire-incidents?bbox=${currentBBox()}`);
|
||||
const pts = await r.json();
|
||||
if (req !== overlayReq.incidents) return;
|
||||
incidentsGroup = renderPoints(incidentsGroup, pts, () => '#ef4444', false);
|
||||
document.getElementById('lp-incidents-count').textContent = pts.length.toLocaleString();
|
||||
addExtraAttrib('NIFC WFIGS');
|
||||
} catch (e) {
|
||||
if (isAbort(e)) return;
|
||||
console.error('Incidents load failed', e);
|
||||
document.getElementById('lp-incidents-count').textContent = 'err';
|
||||
}
|
||||
|
|
@ -2481,13 +2541,14 @@ async function loadAircraft() {
|
|||
}
|
||||
const req = ++overlayReq.ac;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/aircraft?bbox=${currentBBox()}`);
|
||||
const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}`);
|
||||
const pts = await r.json();
|
||||
if (req !== overlayReq.ac) return;
|
||||
acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => altColor((p.extra || {}).alt_baro), true, 'ac');
|
||||
document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString();
|
||||
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
|
||||
} catch (e) {
|
||||
if (isAbort(e)) return;
|
||||
console.error('Aircraft load failed', e);
|
||||
document.getElementById('lp-ac-count').textContent = 'err';
|
||||
}
|
||||
|
|
@ -2501,13 +2562,14 @@ async function loadTrains() {
|
|||
if (!map) return;
|
||||
const req = ++overlayReq.trains;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/trains?bbox=${currentBBox()}`);
|
||||
const r = await overlayFetch(`${API}/api/trains?bbox=${currentBBox()}`);
|
||||
const pts = await r.json();
|
||||
if (req !== overlayReq.trains) return;
|
||||
trainsGroup = renderPoints(trainsGroup, Array.isArray(pts) ? pts : [], p => (p.extra || {}).iconColor || '#c084fc', false, 'train');
|
||||
document.getElementById('lp-trains-count').textContent = (pts.length || 0).toLocaleString();
|
||||
addExtraAttrib('<a href="https://amtraker.com/about">Amtraker</a>');
|
||||
} catch (e) {
|
||||
if (isAbort(e)) return;
|
||||
console.error('Trains load failed', e);
|
||||
document.getElementById('lp-trains-count').textContent = 'err';
|
||||
}
|
||||
|
|
@ -2525,7 +2587,7 @@ async function loadVessels() {
|
|||
}
|
||||
const req = ++overlayReq.vessels;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/vessels?bbox=${currentBBox()}`);
|
||||
const r = await overlayFetch(`${API}/api/vessels?bbox=${currentBBox()}`);
|
||||
const pts = await r.json();
|
||||
if (req !== overlayReq.vessels) return;
|
||||
vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
|
||||
|
|
@ -2535,6 +2597,7 @@ async function loadVessels() {
|
|||
document.getElementById('lp-vessels-count').textContent = (pts.length || 0).toLocaleString();
|
||||
addExtraAttrib('AISStream');
|
||||
} catch (e) {
|
||||
if (isAbort(e)) return;
|
||||
console.error('Vessels load failed', e);
|
||||
document.getElementById('lp-vessels-count').textContent = 'err';
|
||||
}
|
||||
|
|
@ -2548,13 +2611,14 @@ async function loadStorms() {
|
|||
if (!map) return;
|
||||
const req = ++overlayReq.storms;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/storms`);
|
||||
const r = await overlayFetch(`${API}/api/storms`);
|
||||
const pts = await r.json();
|
||||
if (req !== overlayReq.storms) return;
|
||||
stormsGroup = renderPoints(stormsGroup, Array.isArray(pts) ? pts : [], () => '#f472b6', false);
|
||||
document.getElementById('lp-storms-count').textContent = (pts.length || 0).toLocaleString();
|
||||
addExtraAttrib('NHC');
|
||||
} catch (e) {
|
||||
if (isAbort(e)) return;
|
||||
console.error('Storms load failed', e);
|
||||
document.getElementById('lp-storms-count').textContent = 'err';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue