Add NASA GIBS satellite basemap world map tab (Leaflet)
Some checks failed
build-and-deploy / build (push) Failing after 5s

Interactive world map panel for the dashboard:
- Vendored Leaflet 1.9.4 under app/static/vendor/leaflet/ (served via
  new /static mount on the FastAPI app)
- New Map tab: GIBS WMTS raster basemap (BlueMarble_ShadedRelief_Bathymetry,
  VIIRS/MODIS/Aqua CorrectedReflectance_TrueColor, VIIRS_DayNightBand),
  layer picker + UTC date selector (Latest/-7d/-30d quick picks)
- /api/map/layers: curated GIBS catalog (tms, format, has_time, max_zoom)
- /api/map/times: per-layer date windows from GIBS Domains XML (6h cache,
  graceful 502 on GIBS hiccup; static layers 422)
- Fire hotspots (existing /api/fires) + open cameras (/api/cameras) as
  toggleable bbox-scoped overlays; dark UI matching dashboard theme
This commit is contained in:
Sirius DevOps 2026-08-24 17:35:44 -04:00
parent 91f436390b
commit 3aa6f265bb
10 changed files with 1142 additions and 2 deletions

157
app/gibs_map.py Normal file
View file

@ -0,0 +1,157 @@
"""NASA GIBS basemap catalog + time-domain helper for the OSINT map tab.
GIBS (Global Imagery Browse Services) is what worldview.earthdata.nasa.gov
renders from. Tiles are plain XYZ/WebMercator WMTS rasters, CORS-open
(access-control-allow-origin: *), so the browser can pull them directly
no tile proxy needed.
Layer metadata is curated here (identifier, display title, tile matrix set,
format, whether it has a Time dimension). Available time windows per layer
come from GIBS' per-layer Domains XML endpoint (tiny), fetched on demand and
cached for a few hours.
Tile URL template (RESTful WMTS):
https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/{layer}/default/{time}/{tms}/{z}/{y}/{x}.{ext}
The {time} path segment is omitted entirely for static layers (no Time dim).
"""
from __future__ import annotations
import asyncio
import re
import time
from datetime import date, datetime, timedelta
import httpx
GIBS_BASE = "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best"
GIBS_TIMEOUT = 30.0
# ── Curated basemap catalog ────────────────────────────────────────────────
# has_time: True → tile URL includes /default/{time}/; the /api/map/times
# endpoint returns the valid date windows. False → static basemap.
MAP_LAYERS = [
{
"id": "BlueMarble_ShadedRelief_Bathymetry",
"title": "Blue Marble (shaded relief + bathymetry)",
"subtitle": "Static MODIS composite basemap",
"tms": "GoogleMapsCompatible_Level8",
"format": "jpeg",
"has_time": False,
"max_zoom": 8,
},
{
"id": "VIIRS_SNPP_CorrectedReflectance_TrueColor",
"title": "VIIRS S-NPP True Color (daily)",
"subtitle": "Suomi NPP corrected reflectance",
"tms": "GoogleMapsCompatible_Level9",
"format": "jpeg",
"has_time": True,
"max_zoom": 9,
},
{
"id": "MODIS_Terra_CorrectedReflectance_TrueColor",
"title": "MODIS Terra True Color (daily)",
"subtitle": "Terra corrected reflectance",
"tms": "GoogleMapsCompatible_Level9",
"format": "jpeg",
"has_time": True,
"max_zoom": 9,
},
{
"id": "MODIS_Aqua_CorrectedReflectance_TrueColor",
"title": "MODIS Aqua True Color (daily)",
"subtitle": "Aqua corrected reflectance",
"tms": "GoogleMapsCompatible_Level9",
"format": "jpeg",
"has_time": True,
"max_zoom": 9,
},
{
"id": "VIIRS_SNPP_DayNightBand_ENCC",
"title": "VIIRS Night Lights (DNB)",
"subtitle": "Earth at night, enhanced near-constant contrast",
"tms": "GoogleMapsCompatible_Level8",
"format": "png",
"has_time": True,
"max_zoom": 8,
},
]
_LAYER_BY_ID = {l["id"]: l for l in MAP_LAYERS}
# ── Time-domain cache (per layer) ──────────────────────────────────────────
# GIBS Domains XML gives a comma-joined list of ISO ranges: START/END/PERIOD
_DOMAIN_CACHE: dict[str, tuple[float, dict]] = {}
_DOMAIN_TTL = 6 * 3600 # 6h
_HTTPS_LOCK = asyncio.Lock()
# e.g. 2026-07-16/2026-08-24/P1D
_TIME_RANGE_RE = re.compile(
r"^(\d{4}-\d{2}-\d{2})/(\d{4}-\d{2}-\d{2})/(P\d+D|P\d+M|PT\d+H)$"
)
def _parse_time_domain(domain: str) -> dict:
"""Expand a Domains XML <Domain> string into usable date metadata.
Returns {min, max, latest, ranges:[{start,end}]} where ranges keep the
raw GIBS windows so the frontend can warn when a picked date is outside
every window (GIBS still serves nearest-time tiles, but this is honest).
"""
windows = []
min_d, max_d = None, None
for part in domain.split(","):
part = part.strip()
m = _TIME_RANGE_RE.match(part)
if not m:
continue
start = date.fromisoformat(m.group(1))
end = date.fromisoformat(m.group(2))
windows.append({"start": start.isoformat(), "end": end.isoformat()})
if min_d is None or start < min_d:
min_d = start
if max_d is None or end > max_d:
max_d = end
return {
"min": min_d.isoformat() if min_d else None,
"max": max_d.isoformat() if max_d else None,
"latest": max_d.isoformat() if max_d else None,
"ranges": windows,
}
async def fetch_layer_domain(layer_id: str, tms: str) -> dict:
"""Return parsed time domain for a layer, cached for _DOMAIN_TTL."""
now = time.time()
hit = _DOMAIN_CACHE.get(layer_id)
if hit and now - hit[0] < _DOMAIN_TTL:
return hit[1]
url = (
f"{GIBS_BASE}/1.0.0/{layer_id}/default/{tms}/all/all.xml"
)
# One in-flight request per layer; serialize so a burst of layer switches
# doesn't fan out parallel GIBS hits.
async with _HTTPS_LOCK:
hit = _DOMAIN_CACHE.get(layer_id)
if hit and time.time() - hit[0] < _DOMAIN_TTL:
return hit[1]
async with httpx.AsyncClient(timeout=GIBS_TIMEOUT) as client:
resp = await client.get(url)
resp.raise_for_status()
m = re.search(r"<Domain>([^<]+)</Domain>", resp.text)
if not m:
raise ValueError(f"No time <Domain> in GIBS response for {layer_id}")
parsed = _parse_time_domain(m.group(1))
_DOMAIN_CACHE[layer_id] = (time.time(), parsed)
return parsed
def default_time_for(layer_id: str) -> str | None:
"""Most recent data date for a time-aware layer (best effort, from cache)."""
hit = _DOMAIN_CACHE.get(layer_id)
if not hit:
return None
latest = hit[1].get("latest")
return latest or None

View file

@ -21,6 +21,7 @@ from uuid import UUID
import structlog import structlog
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import and_, func, select, text from sqlalchemy import and_, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -841,6 +842,52 @@ async def index():
return FileResponse(str(STATIC_DIR / "index.html")) return FileResponse(str(STATIC_DIR / "index.html"))
# ── NASA GIBS basemap map tab ─────────────────────────────────────────────
@app.get("/api/map/layers")
async def map_layers():
"""Curated NASA GIBS raster basemap layers for the map tab.
Each entry has everything the browser needs to render the WMTS tiles:
id GIBS layer identifier (used in the tile URL path)
title human-readable display name
tms GIBS tile matrix set (GoogleMapsCompatible_LevelN)
format tile image extension (jpeg|png)
has_time whether the layer has a Time dimension (=> date selector)
max_zoom highest native zoom served by that tile matrix set
"""
from gibs_map import MAP_LAYERS
return {"layers": MAP_LAYERS}
@app.get("/api/map/times")
async def map_layer_times(
layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"),
):
"""Available date windows for a time-aware GIBS layer.
Parsed from the layer's Domains XML (GIBS serves nearest-time tiles even
for dates slightly outside a window, but we surface the real ranges so
the UI can clamp the picker and flag out-of-range picks).
"""
from gibs_map import _LAYER_BY_ID, fetch_layer_domain
meta = _LAYER_BY_ID.get(layer)
if not meta:
raise HTTPException(404, f"Unknown GIBS layer: {layer}")
if not meta["has_time"]:
raise HTTPException(422, f"Layer '{layer}' is static (no Time dimension)")
try:
domain = await fetch_layer_domain(meta["id"], meta["tms"])
except Exception as exc: # network / GIBS hiccup → degrade gracefully
logger.warning("gibs_domain_fetch_failed", layer=layer, error=str(exc))
raise HTTPException(502, f"GIBS time domain unavailable: {exc}")
return {"layer": layer, **domain}
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OSINT Dashboard</title> <title>OSINT Dashboard</title>
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
<style> <style>
:root { --bg: #0f172a; --surface: #1e293b; --border: #334155; --text: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --green: #4ade80; --red: #f87171; --yellow: #fbbf24; } :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; } * { margin: 0; padding: 0; box-sizing: border-box; }
@ -60,6 +61,30 @@
.key-msg { margin-top: 0.5rem; font-size: 0.8rem; min-height: 1rem; } .key-msg { margin-top: 0.5rem; font-size: 0.8rem; min-height: 1rem; }
.key-msg.ok { color: var(--green); } .key-msg.ok { color: var(--green); }
.key-msg.err { color: var(--red); } .key-msg.err { color: var(--red); }
/* ── Map tab ─────────────────────────────────────────────── */
.map-toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; margin-bottom: 0.9rem; }
.map-toolbar label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.map-toolbar select, .map-toolbar input[type="date"], .map-toolbar input[type="datetime-local"] {
background: var(--surface); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 0.5rem 0.6rem; font-size: 0.85rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color-scheme: dark;
}
.map-toolbar select:focus, .map-toolbar input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 8px rgba(56,189,248,0.35); }
.map-toolbar .btn { white-space: nowrap; }
.map-toolbar .btn.active { background: var(--accent); color: #0f172a; }
.map-hint { font-size: 0.78rem; color: var(--muted); align-self: center; flex: 1 1 100%; margin-top: -0.2rem; }
#map { width: 100%; height: 70vh; min-height: 420px; border-radius: 10px; border: 1px solid var(--border); z-index: 0; background: #0b1220; }
.leaflet-container { background: #0b1220; font-family: inherit; }
.leaflet-control-zoom a { background: var(--surface) !important; color: var(--text) !important; border-color: var(--border) !important; }
.leaflet-control-zoom a:hover { background: #273449 !important; }
.leaflet-control-attribution { background: rgba(15,23,42,0.85) !important; color: var(--muted) !important; font-size: 10px !important; }
.leaflet-control-attribution a { color: var(--accent) !important; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: var(--surface) !important; color: var(--text) !important; border: 1px solid var(--border); }
.leaflet-popup-content-wrapper a { color: var(--accent); }
.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)); }
</style> </style>
</head> </head>
<body> <body>
@ -106,6 +131,7 @@
<button class="btn" onclick="showTab('entities')">Entities</button> <button class="btn" onclick="showTab('entities')">Entities</button>
<button class="btn" onclick="showTab('ingest')">Ingest</button> <button class="btn" onclick="showTab('ingest')">Ingest</button>
<button class="btn" onclick="showTab('keys')">Keys</button> <button class="btn" onclick="showTab('keys')">Keys</button>
<button class="btn" onclick="showTab('map')">Map</button>
</div> </div>
<!-- Recent Events --> <!-- Recent Events -->
@ -172,6 +198,28 @@
<div class="key-grid" id="keys-grid"></div> <div class="key-grid" id="keys-grid"></div>
</div> </div>
<!-- Map (NASA GIBS satellite basemap) -->
<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>
<div class="map-toolbar">
<label>Layer
<select id="map-layer" onchange="mapLayerChanged()"></select>
</label>
<label id="map-time-label" style="display:none">Date / Time (UTC)
<input type="date" id="map-date" onchange="mapDateChanged()">
</label>
<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>
<!-- Search Results --> <!-- Search Results -->
<div class="section" id="search-results" style="display:none"> <div class="section" id="search-results" style="display:none">
<h2>Search Results (<span id="search-total">0</span>)</h2> <h2>Search Results (<span id="search-total">0</span>)</h2>
@ -182,6 +230,7 @@
</div> </div>
</div> </div>
<script src="/static/vendor/leaflet/leaflet.js"></script>
<script> <script>
const API = ''; const API = '';
@ -343,15 +392,16 @@ async function clearKey(name) {
} }
function showTab(name) { function showTab(name) {
['recent','alerts','entities','ingest','keys'].forEach(t => { ['recent','alerts','entities','ingest','keys','map'].forEach(t => {
document.getElementById('tab-'+t).style.display = t===name?'block':'none'; document.getElementById('tab-'+t).style.display = t===name?'block':'none';
}); });
document.querySelectorAll('.tab-bar .btn').forEach((b,i) => { document.querySelectorAll('.tab-bar .btn').forEach((b,i) => {
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys'][i]===name); b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map'][i]===name);
}); });
if (name==='alerts') loadAlerts(); if (name==='alerts') loadAlerts();
if (name==='entities') loadEntities(); if (name==='entities') loadEntities();
if (name==='keys') loadKeys(); if (name==='keys') loadKeys();
if (name==='map') initMap();
} }
async function ingestRSS() { async function ingestRSS() {
@ -395,6 +445,225 @@ async function checkHealth() {
} }
} }
// ── Map (NASA GIBS satellite basemap) ────────────────────────────────────
let map = null, mapLayer = null, mapInitStarted = false;
let firesGroup = null, camsGroup = null, firesOn = false, camsOn = false;
let mapLayers = []; // catalog from /api/map/layers
let mapDomain = null; // time windows for current layer
let mapLatest = null; // ISO date of most recent imagery
let mapAttributionAdded = null; // last attribution string added to control
async function initMap() {
if (mapInitStarted) { if (map) setTimeout(() => map.invalidateSize(), 60); return; }
mapInitStarted = true;
const hint = document.getElementById('map-hint');
hint.textContent = 'Loading layer catalog…';
try {
const r = await fetch(`${API}/api/map/layers`);
const d = await r.json();
mapLayers = d.layers || [];
const sel = document.getElementById('map-layer');
sel.innerHTML = mapLayers.map(l =>
`<option value="${l.id}">${l.title}</option>`).join('');
// default to the first time-aware layer (more interesting than static basemap)
const preferred = mapLayers.find(l => l.has_time) || mapLayers[0];
if (preferred) sel.value = preferred.id;
map = L.map('map', {
center: [25, 10], zoom: 2,
zoomControl: true,
worldCopyJump: true,
minZoom: 1,
maxZoom: 12,
attributionControl: true,
});
map.attributionControl.setPrefix('');
map.on('moveend', () => { if (firesOn) loadFires(); if (camsOn) loadCams(); });
// default selection = first layer
await mapLayerChanged();
} catch(e) {
hint.textContent = `Failed to load map layers: ${e.message || e}`;
console.error('Map init failed', e);
}
}
function setMapAttribution(text) {
if (!map) return;
if (mapAttributionAdded) map.attributionControl.removeAttribution(mapAttributionAdded);
mapAttributionAdded = text;
if (text) map.attributionControl.addAttribution(text);
}
function tileUrlFor(layerMeta, time) {
// RESTful WMTS template: .../best/{layer}/default[/{time}]/{tms}/{z}/{y}/{x}.{fmt}
const base = `https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layerMeta.id}/default`;
const timePart = layerMeta.has_time ? `/${time || 'default'}` : '';
return `${base}${timePart}/${layerMeta.tms}/{z}/{y}/{x}.${layerMeta.format}`;
}
async function mapLayerChanged() {
const hint = document.getElementById('map-hint');
const sel = document.getElementById('map-layer');
const meta = mapLayers.find(l => l.id === sel.value);
if (!meta) return;
// Show/hide time controls depending on whether layer has a Time dimension
const hasTime = !!meta.has_time;
document.getElementById('map-time-label').style.display = hasTime ? 'flex' : 'none';
document.getElementById('map-latest-btn').style.display = hasTime ? '' : 'none';
document.getElementById('map-7d-btn').style.display = hasTime ? '' : 'none';
document.getElementById('map-30d-btn').style.display = hasTime ? '' : 'none';
let time = null;
if (hasTime) {
hint.textContent = 'Loading available dates…';
const input = document.getElementById('map-date');
try {
const r = await fetch(`${API}/api/map/times?layer=${encodeURIComponent(meta.id)}`);
mapDomain = await r.json();
mapLatest = mapDomain.latest;
if (mapDomain.min && mapDomain.max) {
input.min = mapDomain.min;
input.max = mapDomain.max;
}
input.value = mapLatest || mapDomain.max || '';
time = input.value;
} catch(e) {
// fall back to today if the times endpoint is unreachable
mapDomain = null;
const today = new Date().toISOString().slice(0,10);
input.value = today;
time = today;
}
} else {
mapDomain = null;
mapLatest = null;
time = null;
}
const url = tileUrlFor(meta, time);
const attribution = `NASA GIBS / EOSDIS · <a href="https://earthdata.nasa.gov/gibs" target="_blank" rel="noopener">${meta.id}</a>`;
if (mapLayer) {
mapLayer.setUrl(url);
mapLayer.options.maxNativeZoom = meta.max_zoom;
} else {
mapLayer = L.tileLayer(url, {
maxZoom: 12,
maxNativeZoom: meta.max_zoom,
attribution: '',
}).addTo(map);
}
setMapAttribution(attribution);
hint.textContent = hasTime
? `${meta.subtitle || meta.title} · tile date ${time || 'default'} UTC`
: `${meta.subtitle || meta.title} · static basemap`;
}
function mapDateChanged() {
const meta = mapLayers.find(l => l.id === document.getElementById('map-layer').value);
if (!meta || !meta.has_time) return;
const time = document.getElementById('map-date').value;
if (!time) return;
mapLayer.setUrl(tileUrlFor(meta, time));
document.getElementById('map-hint').textContent =
`${meta.subtitle || meta.title} · tile date ${time} UTC`;
if (mapDomain && mapDomain.ranges && mapDomain.ranges.length) {
const inRange = mapDomain.ranges.some(r => time >= r.start && time <= r.end);
if (!inRange) {
document.getElementById('map-hint').textContent += ' ⚠ outside published windows (GIBS serves nearest-time)';
}
}
}
function mapGoLatest() { document.getElementById('map-date').value = mapLatest || ''; mapDateChanged(); }
function mapGoDays(n) {
const base = mapLatest || document.getElementById('map-date').value;
if (!base) return;
const dt = new Date(base + 'T00:00:00Z');
dt.setUTCDate(dt.getUTCDate() - n);
const iso = dt.toISOString().slice(0,10);
document.getElementById('map-date').value = iso;
mapDateChanged();
}
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)}`;
}
async function mapToggleFires() {
firesOn = !firesOn;
document.getElementById('map-fires-toggle').classList.toggle('active', firesOn);
if (firesOn) await loadFires();
else if (firesGroup) { map.removeLayer(firesGroup); firesGroup = null; }
}
async function loadFires() {
if (!map) return;
try {
const r = await fetch(`${API}/api/fires?bbox=${currentBBox()}&limit=2000`);
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);
document.getElementById('map-hint').textContent =
`${fires.length} fire hotspots in view`;
} 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);
if (camsOn) await loadCams();
else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; }
}
async function loadCams() {
if (!map) return;
try {
const r = await fetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=500`);
const cams = await r.json();
if (camsGroup) map.removeLayer(camsGroup);
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],
});
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);
}));
camsGroup.addTo(map);
document.getElementById('map-hint').textContent =
`${cams.length} open cameras in view`;
} catch(e) {
document.getElementById('map-hint').textContent = `Cameras load failed: ${e.message || e}`;
console.error('Cameras load failed', e);
}
}
// Initial load // Initial load
loadSummary(); loadEvents(); checkHealth(); loadSummary(); loadEvents(); checkHealth();
setInterval(() => { loadSummary(); loadEvents(); checkHealth(); }, 30000); setInterval(() => { loadSummary(); loadEvents(); checkHealth(); }, 30000);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

661
app/static/vendor/leaflet/leaflet.css vendored Normal file
View file

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

6
app/static/vendor/leaflet/leaflet.js vendored Normal file

File diff suppressed because one or more lines are too long