Map: FIRMS heatmap + camera thumbnail popups + unified layer panel
All checks were successful
build-and-deploy / build (push) Successful in 1m37s
All checks were successful
build-and-deploy / build (push) Successful in 1m37s
- Vendored leaflet.heat 0.2.0; fires overlay is now a real heatmap
(L.heatLayer) with intensity driven by brightness or VIIRS confidence
(color mode selector) and a since= time filter (6h/24h/48h/7d/30d/all).
- Camera markers now open popups with the live snapshot thumbnail proxied
through GET /api/cameras/{id}/snapshot (backend TTL cache) + full
metadata (vendor, device, discovery source, first/last seen, coords,
source link); missing-snapshot cams show a placeholder instead of 404.
- New collapsible Layers panel (dark neon) over the map: per-layer
visibility toggles, opacity sliders (basemap/fires/cameras), live
counts legend, and a fire heat-intensity ramp that swaps for the
confidence legend in confidence mode.
- Clamp currentBBox() to world bounds so /api/cameras doesn't 422 at low
zoom (worldCopyJump spans multiple copies).
- Skip overlay reloads on moveend while a popup is open — popup autopan
no longer rebuilds the marker group and closes the popup.
- Zoom control moved to top-right so it doesn't collide with the panel.
This commit is contained in:
parent
347f781cf6
commit
aec3ecae9a
2 changed files with 266 additions and 46 deletions
|
|
@ -85,6 +85,59 @@
|
|||
.leaflet-popup-content { font-size: 0.82rem; }
|
||||
.leaflet-popup-close-button { color: var(--muted) !important; }
|
||||
.leaflet-overlay-pane svg { filter: drop-shadow(0 0 3px rgba(0,0,0,0.6)); }
|
||||
/* ── Map layer control panel (dark neon) ─────────────────── */
|
||||
#map-wrap { position: relative; }
|
||||
#map { z-index: 0; }
|
||||
#layer-panel {
|
||||
position: absolute; top: 12px; left: 12px; z-index: 600;
|
||||
width: 244px; max-height: calc(100% - 24px); overflow: hidden;
|
||||
background: rgba(15,23,42,0.88); backdrop-filter: blur(6px);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
box-shadow: 0 0 18px rgba(56,189,248,0.18), 0 0 2px rgba(56,189,248,0.5);
|
||||
color: var(--text); font-size: 0.78rem; display: flex; flex-direction: column;
|
||||
}
|
||||
.lp-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.55rem 0.7rem; border-bottom: 1px solid var(--border);
|
||||
font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em;
|
||||
text-transform: uppercase; color: var(--accent);
|
||||
text-shadow: 0 0 10px rgba(56,189,248,0.55); cursor: pointer; user-select: none;
|
||||
}
|
||||
.lp-head .lp-caret { color: var(--muted); font-size: 0.8rem; transition: transform .18s; }
|
||||
#layer-panel.collapsed .lp-caret { transform: rotate(-90deg); }
|
||||
.lp-body { padding: 0.55rem 0.7rem 0.65rem; display: flex; flex-direction: column; gap: 0.55rem; overflow-y: auto; }
|
||||
#layer-panel.collapsed .lp-body { display: none; }
|
||||
.lp-layer { display: flex; flex-direction: column; gap: 0.3rem; padding-bottom: 0.5rem; border-bottom: 1px dashed var(--border); }
|
||||
.lp-layer:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.lp-row { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
||||
.lp-name { display: flex; align-items: center; gap: 0.42rem; font-weight: 600; cursor: pointer; text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.7rem; }
|
||||
.lp-name input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; }
|
||||
.lp-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.lp-dot.base { background: #38bdf8; box-shadow: 0 0 7px #38bdf8; }
|
||||
.lp-dot.fires { background: #fb923c; box-shadow: 0 0 7px #fb923c; }
|
||||
.lp-dot.cams { background: #4ade80; box-shadow: 0 0 7px #4ade80; }
|
||||
.lp-count { font-family: ui-monospace, monospace; font-size: 0.72rem; color: var(--accent); }
|
||||
.lp-sub { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
||||
.lp-opacity { display: flex; align-items: center; gap: 0.4rem; font-size: 0.68rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.lp-opacity input[type="range"] { flex: 1; accent-color: var(--accent); height: 4px; cursor: pointer; }
|
||||
.lp-opacity b { color: var(--text); font-family: ui-monospace, monospace; font-weight: 400; min-width: 2.4em; text-align: right; }
|
||||
.lp-select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 0.25rem 0.4rem; font-size: 0.72rem; font-family: ui-monospace, monospace; color-scheme: dark; }
|
||||
.lp-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 8px rgba(56,189,248,0.35); }
|
||||
.lp-legend { border-top: 1px solid var(--border); padding-top: 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.lp-legend h4 { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0; }
|
||||
.lp-ramp { height: 8px; border-radius: 4px; background: linear-gradient(90deg, #3b82f6, #f59e0b, #ef4444, #fde047); box-shadow: 0 0 8px rgba(251,146,60,0.35) inset; }
|
||||
.lp-ramp-scale { display: flex; justify-content: space-between; font-size: 0.6rem; color: var(--muted); }
|
||||
.lp-heat-legend { display: flex; align-items: center; justify-content: space-between; font-size: 0.68rem; color: var(--muted); gap: 0.5rem; }
|
||||
.lp-heat-legend .sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; margin-right: 0.3rem; box-shadow: 0 0 6px currentColor; }
|
||||
.lp-error { color: var(--red); font-size: 0.68rem; }
|
||||
/* ── Camera popup thumbnail ───────────────────────────────── */
|
||||
.cam-pop { min-width: 210px; max-width: 260px; }
|
||||
.cam-pop .thumb { width: 100%; height: 120px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); margin: 0.3rem 0; box-shadow: 0 0 10px rgba(56,189,248,0.25); background: #0b1220; }
|
||||
.cam-pop .thumb.placeholder { display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 0.7rem; height: 80px; }
|
||||
.cam-pop table { width: 100%; font-size: 0.72rem; border-collapse: collapse; }
|
||||
.cam-pop td { padding: 0.12rem 0.2rem; vertical-align: top; }
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -198,10 +251,10 @@
|
|||
<div class="key-grid" id="keys-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Map (NASA GIBS satellite basemap) -->
|
||||
<!-- Map (NASA GIBS satellite basemap + FIRMS heatmap + open cameras) -->
|
||||
<div class="section" id="tab-map" style="display:none">
|
||||
<h2>Global Satellite Map</h2>
|
||||
<p class="sub" style="margin-bottom:0.75rem">NASA GIBS satellite basemap (the same tiles worldview.earthdata.nasa.gov renders). Pick a layer and date, then zoom/pan — fires and open cameras can be overlaid.</p>
|
||||
<p class="sub" style="margin-bottom:0.75rem">NASA GIBS satellite basemap (the same tiles worldview.earthdata.nasa.gov renders). Pick a layer and date, then zoom/pan — fires and open cameras can be overlaid. Use the <b>Layers</b> panel to toggle feeds, adjust opacity, and filter fires by time.</p>
|
||||
<div class="map-toolbar">
|
||||
<label>Layer
|
||||
<select id="map-layer" onchange="mapLayerChanged()"></select>
|
||||
|
|
@ -212,12 +265,62 @@
|
|||
<button class="btn" id="map-latest-btn" onclick="mapGoLatest()" style="display:none">Latest</button>
|
||||
<button class="btn" id="map-7d-btn" onclick="mapGoDays(7)" style="display:none">-7d</button>
|
||||
<button class="btn" id="map-30d-btn" onclick="mapGoDays(30)" style="display:none">-30d</button>
|
||||
<button class="btn" id="map-fires-toggle" onclick="mapToggleFires()">● Fires</button>
|
||||
<button class="btn" id="map-cams-toggle" onclick="mapToggleCams()">◉ Cameras</button>
|
||||
<button class="btn" id="map-reset" onclick="mapResetView()">Reset view</button>
|
||||
<div class="map-hint" id="map-hint"></div>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<div id="map-wrap">
|
||||
<div id="map"></div>
|
||||
<div id="layer-panel">
|
||||
<div class="lp-head" onclick="toggleLayerPanel()">◈ Layers <span class="lp-caret">▼</span></div>
|
||||
<div class="lp-body">
|
||||
<div class="lp-layer">
|
||||
<div class="lp-row">
|
||||
<label class="lp-name"><input type="checkbox" id="lp-base-on" checked onchange="toggleBase()"> <span class="lp-dot base"></span> Basemap</label>
|
||||
<span class="lp-count" id="lp-base-count">GIBS</span>
|
||||
</div>
|
||||
<div class="lp-opacity">Opacity <input type="range" id="lp-base-opacity" min="5" max="100" value="100" oninput="setBaseOpacity(this.value)"><b id="lp-base-val">100%</b></div>
|
||||
</div>
|
||||
<div class="lp-layer">
|
||||
<div class="lp-row">
|
||||
<label class="lp-name"><input type="checkbox" id="lp-fires-on" checked onchange="toggleFires()"> <span class="lp-dot fires"></span> FIRMS Fires</label>
|
||||
<span class="lp-count" id="lp-fires-count">0</span>
|
||||
</div>
|
||||
<div class="lp-opacity">Opacity <input type="range" id="lp-fires-opacity" min="5" max="100" value="65" oninput="setFiresOpacity(this.value)"><b id="lp-fires-val">65%</b></div>
|
||||
<div class="lp-sub">
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;font-size:0.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;">Color
|
||||
<select class="lp-select" id="lp-fires-color" onchange="setFiresColor()">
|
||||
<option value="brightness">Brightness</option>
|
||||
<option value="confidence">Confidence</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;font-size:0.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;">Since
|
||||
<select class="lp-select" id="lp-fires-since" onchange="setFiresSince()">
|
||||
<option value="">All time</option>
|
||||
<option value="6h">Last 6h</option>
|
||||
<option value="24h" selected>Last 24h</option>
|
||||
<option value="48h">Last 48h</option>
|
||||
<option value="7d">Last 7d</option>
|
||||
<option value="30d">Last 30d</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lp-layer">
|
||||
<div class="lp-row">
|
||||
<label class="lp-name"><input type="checkbox" id="lp-cams-on" checked onchange="toggleCams()"> <span class="lp-dot cams"></span> Open Cameras</label>
|
||||
<span class="lp-count" id="lp-cams-count">0</span>
|
||||
</div>
|
||||
<div class="lp-opacity">Opacity <input type="range" id="lp-cams-opacity" min="5" max="100" value="100" oninput="setCamsOpacity(this.value)"><b id="lp-cams-val">100%</b></div>
|
||||
</div>
|
||||
<div class="lp-legend">
|
||||
<h4>Fire heat intensity</h4>
|
||||
<div class="lp-ramp"></div>
|
||||
<div class="lp-ramp-scale"><span>cool / low</span><span>hot / high</span></div>
|
||||
<div class="lp-heat-legend" id="lp-heat-legend"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
|
|
@ -231,6 +334,7 @@
|
|||
</div>
|
||||
|
||||
<script src="/static/vendor/leaflet/leaflet.js"></script>
|
||||
<script src="/static/vendor/leaflet/leaflet.heat.js"></script>
|
||||
<script>
|
||||
const API = '';
|
||||
|
||||
|
|
@ -445,9 +549,11 @@ async function checkHealth() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Map (NASA GIBS satellite basemap) ────────────────────────────────────
|
||||
// ── Map (NASA GIBS basemap + FIRMS heatmap + open cameras) ───────────────
|
||||
let map = null, mapLayer = null, mapInitStarted = false;
|
||||
let firesGroup = null, camsGroup = null, firesOn = false, camsOn = 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 mapLayers = []; // catalog from /api/map/layers
|
||||
let mapDomain = null; // time windows for current layer
|
||||
let mapLatest = null; // ISO date of most recent imagery
|
||||
|
|
@ -470,16 +576,32 @@ async function initMap() {
|
|||
if (preferred) sel.value = preferred.id;
|
||||
map = L.map('map', {
|
||||
center: [25, 10], zoom: 2,
|
||||
zoomControl: true,
|
||||
zoomControl: false,
|
||||
worldCopyJump: true,
|
||||
minZoom: 1,
|
||||
maxZoom: 12,
|
||||
attributionControl: true,
|
||||
});
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
map.attributionControl.setPrefix('');
|
||||
map.on('moveend', () => { if (firesOn) loadFires(); if (camsOn) loadCams(); });
|
||||
updateHeatLegend();
|
||||
// Reload overlays when the user pans/zooms. Skip when a popup is open:
|
||||
// opening a camera popup auto-pans the map to fit it, and that moveend
|
||||
// must NOT rebuild the marker group under the open popup (which would
|
||||
// close it). Leaflet closes popups on manual drag, so a live popup here
|
||||
// means the move was autopan — safe to skip.
|
||||
map.on('moveend', () => {
|
||||
if (map._popup) return;
|
||||
if (firesOn) loadFires();
|
||||
if (camsOn) loadCams();
|
||||
});
|
||||
// default selection = first layer
|
||||
await mapLayerChanged();
|
||||
// Overlays default ON (checked in the layer panel)
|
||||
firesOn = document.getElementById('lp-fires-on').checked;
|
||||
camsOn = document.getElementById('lp-cams-on').checked;
|
||||
if (firesOn) loadFires();
|
||||
if (camsOn) loadCams();
|
||||
} catch(e) {
|
||||
hint.textContent = `Failed to load map layers: ${e.message || e}`;
|
||||
console.error('Map init failed', e);
|
||||
|
|
@ -589,50 +711,131 @@ function mapResetView() { if (map) map.setView([25, 10], 2); }
|
|||
|
||||
function currentBBox() {
|
||||
const b = map.getBounds();
|
||||
return `${b.getWest().toFixed(4)},${b.getSouth().toFixed(4)},${b.getEast().toFixed(4)},${b.getNorth().toFixed(4)}`;
|
||||
// Clamp to world bounds: at low zoom with worldCopyJump the map spans
|
||||
// multiple world copies, and /api/cameras rejects coords outside ±180/±90.
|
||||
const west = Math.max(-180, Math.min(180, b.getWest()));
|
||||
const east = Math.max(-180, Math.min(180, b.getEast()));
|
||||
const south = Math.max(-90, Math.min(90, b.getSouth()));
|
||||
const north = Math.max(-90, Math.min(90, b.getNorth()));
|
||||
return `${west.toFixed(4)},${south.toFixed(4)},${east.toFixed(4)},${north.toFixed(4)}`;
|
||||
}
|
||||
|
||||
async function mapToggleFires() {
|
||||
firesOn = !firesOn;
|
||||
document.getElementById('map-fires-toggle').classList.toggle('active', firesOn);
|
||||
function toggleLayerPanel() {
|
||||
document.getElementById('layer-panel').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// ── Basemap visibility / opacity ─────────────────────────────────────────
|
||||
function toggleBase() {
|
||||
const on = document.getElementById('lp-base-on').checked;
|
||||
if (mapLayer) mapLayer.setOpacity(on ? baseOpacity : 0);
|
||||
}
|
||||
function setBaseOpacity(v) {
|
||||
baseOpacity = v / 100;
|
||||
document.getElementById('lp-base-val').textContent = `${Math.round(v)}%`;
|
||||
if (mapLayer && document.getElementById('lp-base-on').checked) mapLayer.setOpacity(baseOpacity);
|
||||
}
|
||||
|
||||
// ── FIRMS fire heatmap ───────────────────────────────────────────────────
|
||||
function firesIntensity(f) {
|
||||
// Color-by mode drives the intensity fed to leaflet.heat:
|
||||
// * brightness — normalize bright_ti4 (~290–400K) into 0..1
|
||||
// * confidence — VIIRS confidence rank (l=0.35, n=0.6, h=1.0)
|
||||
if (firesColor === 'confidence') {
|
||||
return f.confidence === 'h' ? 1.0 : f.confidence === 'l' ? 0.35 : 0.6;
|
||||
}
|
||||
const b = f.brightness || 300;
|
||||
return Math.max(0.05, Math.min(1, (b - 290) / 110));
|
||||
}
|
||||
function firesGradient() {
|
||||
// Neon ramp that reads on the dark basemap; low→high intensity.
|
||||
return firesColor === 'confidence'
|
||||
? { 0.2: '#3b82f6', 0.5: '#fbbf24', 0.75: '#fb923c', 1.0: '#ef4444' }
|
||||
: { 0.1: '#2563eb', 0.4: '#38bdf8', 0.65: '#f59e0b', 0.85: '#ef4444', 1.0: '#fde047' };
|
||||
}
|
||||
function updateHeatLegend() {
|
||||
const el = document.getElementById('lp-heat-legend');
|
||||
if (firesColor === 'confidence') {
|
||||
el.innerHTML = '<span><span class="sw" style="background:#fbbf24">l</span><span class="sw" style="background:#fb923c">n</span><span class="sw" style="background:#ef4444">h</span></span><span>VIIRS confidence</span>';
|
||||
} else {
|
||||
el.innerHTML = '<span>~290K</span><span>~400K</span>';
|
||||
}
|
||||
}
|
||||
function sinceToISO(sel) {
|
||||
if (!sel) return '';
|
||||
const m = sel.match(/^(\d+)([hd])$/);
|
||||
if (!m) return '';
|
||||
const n = parseInt(m[1], 10) * (m[2] === 'h' ? 3600 : 86400) * 1000;
|
||||
return new Date(Date.now() - n).toISOString();
|
||||
}
|
||||
async function toggleFires() {
|
||||
firesOn = document.getElementById('lp-fires-on').checked;
|
||||
if (firesOn) await loadFires();
|
||||
else if (firesGroup) { map.removeLayer(firesGroup); firesGroup = null; }
|
||||
else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; }
|
||||
document.getElementById('lp-fires-count').textContent = '0';
|
||||
}
|
||||
function setFiresOpacity(v) {
|
||||
firesOpacity = v / 100;
|
||||
document.getElementById('lp-fires-val').textContent = `${Math.round(v)}%`;
|
||||
if (firesHeat && firesHeat._canvas) firesHeat._canvas.style.opacity = firesOpacity;
|
||||
}
|
||||
function setFiresColor() {
|
||||
firesColor = document.getElementById('lp-fires-color').value;
|
||||
updateHeatLegend();
|
||||
if (firesOn) loadFires(); // re-weight intensities
|
||||
}
|
||||
function setFiresSince() {
|
||||
firesSince = document.getElementById('lp-fires-since').value;
|
||||
if (firesOn) loadFires();
|
||||
}
|
||||
|
||||
async function loadFires() {
|
||||
if (!map) return;
|
||||
try {
|
||||
const r = await fetch(`${API}/api/fires?bbox=${currentBBox()}&limit=2000`);
|
||||
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 (firesGroup) map.removeLayer(firesGroup);
|
||||
firesGroup = L.layerGroup(fires.map(f => {
|
||||
const color = f.confidence === 'h' ? '#f87171'
|
||||
: f.confidence === 'l' ? '#fbbf24' : '#fb923c';
|
||||
return L.circleMarker([f.latitude, f.longitude], {
|
||||
radius: Math.min(6, 2 + (f.brightness || 300) / 100),
|
||||
color: color, weight: 1, fillColor: color, fillOpacity: 0.6,
|
||||
}).bindPopup(
|
||||
`<b>Fire hotspot</b><br>brightness: ${(f.brightness||0).toFixed(1)}K` +
|
||||
`<br>FRP: ${(f.frp||0).toFixed(1)} MW<br>confidence: ${f.confidence}` +
|
||||
`<br>sat: ${f.satellite} ${f.instrument || ''}<br>acquired: ${f.acq_time}`
|
||||
);
|
||||
}));
|
||||
firesGroup.addTo(map);
|
||||
if (firesHeat) map.removeLayer(firesHeat);
|
||||
const pts = fires.map(f => [f.latitude, f.longitude, firesIntensity(f)]);
|
||||
firesHeat = L.heatLayer(pts, {
|
||||
radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2,
|
||||
gradient: firesGradient(),
|
||||
}).addTo(map);
|
||||
firesHeat._canvas.style.opacity = firesOpacity;
|
||||
document.getElementById('lp-fires-count').textContent = fires.length.toLocaleString();
|
||||
document.getElementById('map-hint').textContent =
|
||||
`${fires.length} fire hotspots in view`;
|
||||
`${fires.length.toLocaleString()} fire hotspots in view` +
|
||||
(since ? ` · since ${since.slice(0,16).replace('T',' ')}Z` : '');
|
||||
} catch(e) {
|
||||
document.getElementById('map-hint').textContent = `Fires load failed: ${e.message || e}`;
|
||||
console.error('Fires load failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function mapToggleCams() {
|
||||
camsOn = !camsOn;
|
||||
document.getElementById('map-cams-toggle').classList.toggle('active', camsOn);
|
||||
// ── Open IP cameras (thumbnail popups) ───────────────────────────────────
|
||||
async function toggleCams() {
|
||||
camsOn = document.getElementById('lp-cams-on').checked;
|
||||
if (camsOn) await loadCams();
|
||||
else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; }
|
||||
document.getElementById('lp-cams-count').textContent = '0';
|
||||
}
|
||||
function setCamsOpacity(v) {
|
||||
camsOpacity = v / 100;
|
||||
document.getElementById('lp-cams-val').textContent = `${Math.round(v)}%`;
|
||||
if (camsGroup) camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity));
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g,
|
||||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function camThumb(c) {
|
||||
// Live snapshot proxied through the backend TTL cache. Only render the
|
||||
// <img> when the camera actually has a snapshot URL (else the backend
|
||||
// 404s); onerror swaps to a placeholder so a dead cam never breaks layout.
|
||||
if (!c.snapshot_url) return '<div class="thumb placeholder">no snapshot available</div>';
|
||||
const onerr = "this.classList.add('placeholder'); this.onerror=null; this.alt='snapshot unavailable'; this.removeAttribute('src');";
|
||||
return `<img class="thumb" src="/api/cameras/${esc(c.id)}/snapshot" alt="live snapshot" loading="lazy" onerror="${onerr}">`;
|
||||
}
|
||||
|
||||
async function loadCams() {
|
||||
if (!map) return;
|
||||
try {
|
||||
|
|
@ -642,22 +845,28 @@ async function loadCams() {
|
|||
camsGroup = L.layerGroup(cams.map(c => {
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:#38bdf8;box-shadow:0 0 8px #38bdf8;border:1px solid #0f172a;"></span>',
|
||||
iconSize: [11, 11], iconAnchor: [5, 5],
|
||||
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],
|
||||
});
|
||||
const camLink = c.snapshot_url
|
||||
? `<br><a href="/api/cameras/${c.id}/snapshot" target="_blank">snapshot</a>`
|
||||
: '';
|
||||
return L.marker([c.lat, c.lon], { icon })
|
||||
.bindPopup(`<b>${c.location_name || 'Open camera'}</b>` +
|
||||
`<br>vendor: ${c.vendor || '?'} ${c.device_type || ''}` +
|
||||
`<br>src: ${c.discovery_source || '?'}` +
|
||||
(c.source_url ? `<br><a href="${c.source_url}" target="_blank" rel="noopener">source</a>` : '') +
|
||||
camLink);
|
||||
.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>` +
|
||||
`<tr><td class="k">Vendor</td><td>${esc(c.vendor || '?')} ${esc(c.device_type || '')}</td></tr>` +
|
||||
`<tr><td class="k">Source</td><td>${esc(c.discovery_source || '?')}</td></tr>` +
|
||||
`<tr><td class="k">First seen</td><td>${esc((c.first_seen||'').slice(0,16).replace('T',' '))}</td></tr>` +
|
||||
`<tr><td class="k">Last seen</td><td>${esc((c.last_seen||'').slice(0,16).replace('T',' '))}</td></tr>` +
|
||||
`<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>` +
|
||||
(c.source_url ? `<a href="${esc(c.source_url)}" target="_blank" rel="noopener">source page ↗</a>` : '') +
|
||||
`</div>`);
|
||||
}));
|
||||
camsGroup.addTo(map);
|
||||
camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity));
|
||||
document.getElementById('lp-cams-count').textContent = cams.length.toLocaleString();
|
||||
document.getElementById('map-hint').textContent =
|
||||
`${cams.length} open cameras in view`;
|
||||
`${cams.length.toLocaleString()} open cameras in view`;
|
||||
} catch(e) {
|
||||
document.getElementById('map-hint').textContent = `Cameras load failed: ${e.message || e}`;
|
||||
console.error('Cameras load failed', e);
|
||||
|
|
|
|||
11
app/static/vendor/leaflet/leaflet.heat.js
vendored
Normal file
11
app/static/vendor/leaflet/leaflet.heat.js
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
(c) 2014, Vladimir Agafonkin
|
||||
simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas
|
||||
https://github.com/mourner/simpleheat
|
||||
*/
|
||||
!function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/*
|
||||
(c) 2014, Vladimir Agafonkin
|
||||
Leaflet.heat, a tiny and fast heatmap plugin for Leaflet.
|
||||
https://github.com/Leaflet/Leaflet.heat
|
||||
*/
|
||||
L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)};
|
||||
Loading…
Add table
Reference in a new issue