Compare commits

...

2 commits

Author SHA1 Message Date
Sirius DevOps
51e906c41f Merge remote-tracking branch 'forgejo/master' into frontend-overhaul
# Conflicts:
#	app/static/index.html
2026-08-27 17:33:12 -04:00
Sirius DevOps
5cea0a5940 map: cluster camera markers so cameras stay visible when zoomed in
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
2026-08-27 17:00:09 -04:00
5 changed files with 123 additions and 5 deletions

View file

@ -1003,7 +1003,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 ─────────────────────────────────────────────

View file

@ -8,6 +8,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<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>
/* ═══════════════════════════════════════════════════════════════
OSINT//DASHBOARD — "SECTION 9" GHOST-SHELL HUD THEME
@ -211,6 +213,20 @@
.leaflet-overlay-pane svg { filter: drop-shadow(0 0 3px rgba(0,0,0,0.6)); }
.leaflet-div-icon { background: transparent; border: none; }
/* ── 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(3, 6, 9, 0.9);
}
.marker-cluster-small div { font-size: 10px; }
.marker-cluster-medium div { font-size: 11px; }
.marker-cluster-large div { font-size: 12px; }
/* ── Map hint + HUD chips ── */
.map-hint {
position: absolute; left: 12px; bottom: 12px; z-index: 500;
@ -947,6 +963,7 @@
</footer>
<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>
@ -1542,6 +1559,7 @@ let firesHeat = null, camsGroup = null, firesOn = false, camsOn = false;
let blipsGroup = null, blipsOn = false, blipsSince = '24h';
let firesOpacity = 0.65, firesColor = 'brightness', firesSince = '24h';
let camsOpacity = 1.0, baseOpacity = 1.0;
let camReq = 0, fireReq = 0, blipReq = 0; // stale-response guards (rapid zoom races)
let mapLayers = [];
let mapDomain = null;
let mapLatest = null;
@ -1831,12 +1849,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, {
@ -1916,17 +1936,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>'}` +
@ -1938,8 +1973,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();
@ -1978,12 +2013,14 @@ function setBlipsSince() {
}
async function loadBlips() {
if (!map) return;
const req = ++blipReq;
try {
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 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);
blipsGroup = L.layerGroup(evs.map(ev => {
const col = blipColor(ev.source_type);

View 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;
}

View 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;
}

File diff suppressed because one or more lines are too long