map: cluster camera markers so cameras stay visible when zoomed in
All checks were successful
build-and-deploy / build (push) Successful in 4m33s
All checks were successful
build-and-deploy / build (push) Successful in 4m33s
Zooming in replaced the marker group with a bbox subset, so dense world views (5k dots) thinned to a handful of pins — or zero on a wrapped world copy, where currentBBox collapsed to a sliver and the API returned ~nothing. Cameras only seemed to exist when zoomed out. - vendor leaflet.markercluster 1.5.3; camera markers now render as numbered neon-green clusters at low zoom and split into individual dots as you zoom, so camera coverage is visible at every zoom level (spiderfy at max zoom) - guard against null lat/lon rows (no phantom markers at 0,0) - stale-response guard for loadCams/loadFires: rapid wheel zoom can resolve fetches out of order and a stale bbox response blanked the map - serve index.html with Cache-Control: no-cache so the browser never keeps serving a pre-deploy copy after fixes land
This commit is contained in:
parent
6f0fc8dbe3
commit
5cea0a5940
5 changed files with 121 additions and 5 deletions
|
|
@ -966,7 +966,12 @@ async def list_news_summaries(
|
|||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||
# Never let browsers serve a stale copy of the app shell: no Cache-Control
|
||||
# means heuristic caching, and a stale index.html froze older bugs in users'
|
||||
# tabs after deploys. Revalidate every load (ETag still returns 304).
|
||||
resp = FileResponse(str(STATIC_DIR / "index.html"))
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
|
||||
# ── NASA GIBS basemap map tab ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OSINT Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
|
||||
<link rel="stylesheet" href="/static/vendor/leaflet/MarkerCluster.css">
|
||||
<link rel="stylesheet" href="/static/vendor/leaflet/MarkerCluster.Default.css">
|
||||
<style>
|
||||
:root { --bg: #0f172a; --surface: #1e293b; --border: #334155; --text: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --green: #4ade80; --red: #f87171; --yellow: #fbbf24; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
|
@ -140,6 +142,20 @@
|
|||
.cam-pop td.k { color: var(--muted); text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.04em; white-space: nowrap; width: 34%; }
|
||||
.cam-pop a { color: var(--accent); word-break: break-all; }
|
||||
|
||||
/* ── Camera marker clusters (neon green, matches the dots) ──── */
|
||||
.marker-cluster-small, .marker-cluster-medium, .marker-cluster-large {
|
||||
background-color: rgba(74, 222, 128, 0.22);
|
||||
}
|
||||
.marker-cluster-small div, .marker-cluster-medium div, .marker-cluster-large div {
|
||||
background-color: rgba(16, 185, 129, 0.92);
|
||||
color: #04150c;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 14px rgba(74, 222, 128, 0.55), 0 0 2px rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
.marker-cluster-small div { font-size: 10px; }
|
||||
.marker-cluster-medium div { font-size: 11px; }
|
||||
.marker-cluster-large div { font-size: 12px; }
|
||||
|
||||
/* ── News panel (scraper feed + exec summaries) ─────────────── */
|
||||
.news-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-bottom: 0.9rem; flex-wrap: wrap; }
|
||||
.news-refresh { display: flex; align-items: center; gap: 0.6rem; }
|
||||
|
|
@ -415,6 +431,7 @@
|
|||
</div>
|
||||
|
||||
<script src="/static/vendor/leaflet/leaflet.js"></script>
|
||||
<script src="/static/vendor/leaflet/leaflet.markercluster.js"></script>
|
||||
<script src="/static/vendor/leaflet/leaflet.heat.js"></script>
|
||||
<script src="/static/vendor/hls/hls.min.js"></script>
|
||||
<script>
|
||||
|
|
@ -753,6 +770,7 @@ let map = null, mapLayer = null, mapInitStarted = false;
|
|||
let firesHeat = null, camsGroup = null, firesOn = false, camsOn = false;
|
||||
let firesOpacity = 0.65, firesColor = 'brightness', firesSince = '24h';
|
||||
let camsOpacity = 1.0, baseOpacity = 1.0;
|
||||
let camReq = 0, fireReq = 0; // stale-response guards (rapid zoom races)
|
||||
let mapLayers = []; // catalog from /api/map/layers
|
||||
let mapDomain = null; // time windows for current layer
|
||||
let mapLatest = null; // ISO date of most recent imagery
|
||||
|
|
@ -1031,12 +1049,14 @@ function setFiresSince() {
|
|||
}
|
||||
async function loadFires() {
|
||||
if (!map) return;
|
||||
const req = ++fireReq;
|
||||
try {
|
||||
let url = `${API}/api/fires?bbox=${currentBBox()}&limit=2000`;
|
||||
const since = sinceToISO(firesSince);
|
||||
if (since) url += `&since=${encodeURIComponent(since)}`;
|
||||
const r = await fetch(url);
|
||||
const fires = await r.json();
|
||||
if (req !== fireReq) return; // superseded by a newer pan/zoom
|
||||
if (firesHeat) map.removeLayer(firesHeat);
|
||||
const pts = fires.map(f => [f.latitude, f.longitude, firesIntensity(f)]);
|
||||
firesHeat = L.heatLayer(pts, {
|
||||
|
|
@ -1113,17 +1133,32 @@ function camThumb(c) {
|
|||
}
|
||||
async function loadCams() {
|
||||
if (!map) return;
|
||||
const req = ++camReq;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=5000`);
|
||||
const cams = await r.json();
|
||||
if (req !== camReq) return; // superseded by a newer pan/zoom
|
||||
if (camsGroup) map.removeLayer(camsGroup);
|
||||
camsGroup = L.layerGroup(cams.map(c => {
|
||||
// Clustered markers: camera coverage stays visible as numbered
|
||||
// clusters at every zoom instead of vanishing into a sparse/empty
|
||||
// viewport when zoomed in (and a 5000-dot soup when zoomed out).
|
||||
camsGroup = L.markerClusterGroup({
|
||||
maxClusterRadius: 50,
|
||||
showCoverageOnHover: false,
|
||||
spiderfyOnMaxZoom: true,
|
||||
chunkedLoading: true,
|
||||
chunkInterval: 100,
|
||||
chunkDelay: 10,
|
||||
});
|
||||
cams.forEach(c => {
|
||||
// Never render a phantom marker for a camera with no coords.
|
||||
if (c.lat == null || c.lon == null) return;
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#4ade80;box-shadow:0 0 8px #4ade80,0 0 2px #0f172a;border:1px solid #0f172a;"></span>',
|
||||
iconSize: [12, 12], iconAnchor: [6, 6],
|
||||
});
|
||||
return L.marker([c.lat, c.lon], { icon })
|
||||
camsGroup.addLayer(L.marker([c.lat, c.lon], { icon })
|
||||
.bindPopup(`<div class="cam-pop">` +
|
||||
`<b>${esc(c.location_name || 'Open camera')}</b>` +
|
||||
`${c.id ? camThumb(c) : '<div class="thumb placeholder">no snapshot</div>'}` +
|
||||
|
|
@ -1135,8 +1170,8 @@ async function loadCams() {
|
|||
`<tr><td class="k">Coords</td><td>${(c.lat!=null&&c.lon!=null) ? c.lat.toFixed(3)+', '+c.lon.toFixed(3) : 'unknown'}</td></tr>` +
|
||||
`</table>` +
|
||||
camSourceLink(c) +
|
||||
`</div>`);
|
||||
}));
|
||||
`</div>`));
|
||||
});
|
||||
camsGroup.addTo(map);
|
||||
camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity));
|
||||
document.getElementById('lp-cams-count').textContent = cams.length.toLocaleString();
|
||||
|
|
|
|||
60
app/static/vendor/leaflet/MarkerCluster.Default.css
vendored
Normal file
60
app/static/vendor/leaflet/MarkerCluster.Default.css
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
.marker-cluster-small {
|
||||
background-color: rgba(181, 226, 140, 0.6);
|
||||
}
|
||||
.marker-cluster-small div {
|
||||
background-color: rgba(110, 204, 57, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-medium {
|
||||
background-color: rgba(241, 211, 87, 0.6);
|
||||
}
|
||||
.marker-cluster-medium div {
|
||||
background-color: rgba(240, 194, 12, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-large {
|
||||
background-color: rgba(253, 156, 115, 0.6);
|
||||
}
|
||||
.marker-cluster-large div {
|
||||
background-color: rgba(241, 128, 23, 0.6);
|
||||
}
|
||||
|
||||
/* IE 6-8 fallback colors */
|
||||
.leaflet-oldie .marker-cluster-small {
|
||||
background-color: rgb(181, 226, 140);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-small div {
|
||||
background-color: rgb(110, 204, 57);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-medium {
|
||||
background-color: rgb(241, 211, 87);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-medium div {
|
||||
background-color: rgb(240, 194, 12);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-large {
|
||||
background-color: rgb(253, 156, 115);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-large div {
|
||||
background-color: rgb(241, 128, 23);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
background-clip: padding-box;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.marker-cluster div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
|
||||
text-align: center;
|
||||
border-radius: 15px;
|
||||
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.marker-cluster span {
|
||||
line-height: 30px;
|
||||
}
|
||||
14
app/static/vendor/leaflet/MarkerCluster.css
vendored
Normal file
14
app/static/vendor/leaflet/MarkerCluster.css
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
|
||||
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.leaflet-cluster-spider-leg {
|
||||
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
|
||||
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
|
||||
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
|
||||
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
|
||||
}
|
||||
2
app/static/vendor/leaflet/leaflet.markercluster.js
vendored
Normal file
2
app/static/vendor/leaflet/leaflet.markercluster.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue