osint-dashboard/app/static/index.html
Sirius DevOps 3aa6f265bb
Some checks failed
build-and-deploy / build (push) Failing after 5s
Add NASA GIBS satellite basemap world map tab (Leaflet)
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
2026-08-24 17:35:44 -04:00

672 lines
34 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-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">
<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; }
body { font-family: -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; }
header h1 { font-size: 1.25rem; color: var(--accent); }
.status { font-size: 0.85rem; color: var(--muted); }
.status .ok { color: var(--green); }
.container { max-width: 1400px; margin: 0 auto; padding: 1.5rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; }
.card h3 { font-size: 0.8rem; text-transform: uppercase; color: var(--muted); margin-bottom: 0.5rem; }
.card .value { font-size: 2rem; font-weight: 700; }
.card .sub { font-size: 0.85rem; color: var(--muted); margin-top: 0.25rem; }
.section { margin-bottom: 1.5rem; }
.section h2 { font-size: 1rem; margin-bottom: 0.75rem; color: var(--accent); }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; font-size: 0.8rem; }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
.badge-positive { background: #052e16; color: var(--green); }
.badge-negative { background: #450a0a; color: var(--red); }
.badge-neutral { background: #1e293b; color: var(--muted); }
.badge-high { background: #450a0a; color: var(--red); }
.badge-medium { background: #451a03; color: var(--yellow); }
.badge-low { background: #052e16; color: var(--green); }
.search-box { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.search-box input { flex: 1; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 0.6rem 1rem; color: var(--text); font-size: 0.9rem; }
.search-box input:focus { outline: none; border-color: var(--accent); }
.search-box button { background: var(--accent); color: #0f172a; border: none; border-radius: 6px; padding: 0.6rem 1.25rem; font-weight: 500; cursor: pointer; }
.btn { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer; font-size: 0.85rem; }
.btn:hover { border-color: var(--accent); }
.sentiment-bar { display: flex; height: 24px; border-radius: 4px; overflow: hidden; margin-top: 0.5rem; }
.sentiment-bar div { transition: width 0.3s; }
.tab-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.tab-bar .btn.active { background: var(--accent); color: #0f172a; }
.key-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
.key-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1rem 1.1rem; box-shadow: 0 0 18px rgba(56,189,248,0.06); transition: box-shadow .2s, border-color .2s; }
.key-card:hover { box-shadow: 0 0 22px rgba(56,189,248,0.14); }
.key-card.set { border-color: rgba(74,222,128,0.5); box-shadow: 0 0 18px rgba(74,222,128,0.14); }
.key-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; }
.key-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; font-weight: 600; color: var(--accent); word-break: break-all; }
.key-status { font-size: 0.72rem; font-family: ui-monospace, monospace; padding: 0.2rem 0.55rem; border-radius: 9999px; white-space: nowrap; }
.key-status.set { background: rgba(74,222,128,0.12); color: var(--green); box-shadow: 0 0 10px rgba(74,222,128,0.35); }
.key-status.missing { background: rgba(248,113,113,0.12); color: var(--red); box-shadow: 0 0 10px rgba(248,113,113,0.25); }
.key-desc { font-size: 0.82rem; color: var(--muted); margin-bottom: 0.7rem; }
.key-hint { font-size: 0.72rem; color: var(--muted); opacity: 0.8; margin-top: 0.15rem; }
.key-form { display: flex; gap: 0.5rem; }
.key-form input { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.6rem; color: var(--text); font-size: 0.85rem; font-family: ui-monospace, monospace; }
.key-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 8px rgba(56,189,248,0.35); }
.key-form .btn { white-space: nowrap; }
.key-form .btn-danger { border-color: var(--red); color: var(--red); }
.key-form .btn-danger:hover { background: rgba(248,113,113,0.12); }
.key-msg { margin-top: 0.5rem; font-size: 0.8rem; min-height: 1rem; }
.key-msg.ok { color: var(--green); }
.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>
</head>
<body>
<header>
<h1>OSINT Dashboard</h1>
<div class="status">
Status: <span id="health" class="ok">checking...</span>
&nbsp;|&nbsp; Last update: <span id="last-update">-</span>
</div>
</header>
<div class="container">
<!-- Summary Cards -->
<div class="grid" id="summary-cards">
<div class="card"><h3>Total Events</h3><div class="value" id="total-events">-</div></div>
<div class="card"><h3>Events (24h)</h3><div class="value" id="events-24h">-</div><div class="sub">last 24 hours</div></div>
<div class="card"><h3>Active Sources</h3><div class="value" id="active-sources">-</div></div>
<div class="card"><h3>Open Alerts</h3><div class="value" id="open-alerts" style="color:var(--red)">-</div></div>
<div class="card"><h3>Tracked Entities</h3><div class="value" id="tracked-entities">-</div></div>
<div class="card">
<h3>Sentiment (24h)</h3>
<div class="sentiment-bar">
<div id="sent-pos" style="background:var(--green)"></div>
<div id="sent-neu" style="background:var(--muted)"></div>
<div id="sent-neg" style="background:var(--red)"></div>
</div>
<div class="sub" id="sent-detail"></div>
</div>
</div>
<!-- Search -->
<div class="section">
<h2>Search Events</h2>
<div class="search-box">
<input type="text" id="search-q" placeholder="Search events by keyword..." />
<button onclick="searchEvents()">Search</button>
</div>
</div>
<!-- Tabs -->
<div class="tab-bar">
<button class="btn active" onclick="showTab('recent')">Recent Events</button>
<button class="btn" onclick="showTab('alerts')">Alerts</button>
<button class="btn" onclick="showTab('entities')">Entities</button>
<button class="btn" onclick="showTab('ingest')">Ingest</button>
<button class="btn" onclick="showTab('keys')">Keys</button>
<button class="btn" onclick="showTab('map')">Map</button>
</div>
<!-- Recent Events -->
<div class="section" id="tab-recent">
<h2>Recent Events</h2>
<table>
<thead><tr><th>Time</th><th>Source</th><th>Title</th><th>Sentiment</th><th>Location</th></tr></thead>
<tbody id="events-body"></tbody>
</table>
</div>
<!-- Alerts -->
<div class="section" id="tab-alerts" style="display:none">
<h2>Open Alerts</h2>
<table>
<thead><tr><th>Time</th><th>Severity</th><th>Type</th><th>Title</th></tr></thead>
<tbody id="alerts-body"></tbody>
</table>
</div>
<!-- Entities -->
<div class="section" id="tab-entities" style="display:none">
<h2>Tracked Entities</h2>
<table>
<thead><tr><th>Name</th><th>Type</th><th>Events</th><th>Last Seen</th></tr></thead>
<tbody id="entities-body"></tbody>
</table>
</div>
<!-- Ingest -->
<div class="section" id="tab-ingest" style="display:none">
<h2>Data Ingestion</h2>
<div class="grid">
<div class="card">
<h3>RSS Feed</h3>
<div style="margin-top:0.5rem;display:flex;gap:0.5rem">
<input type="text" id="rss-url" placeholder="https://example.com/feed" style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:0.4rem;color:var(--text);font-size:0.85rem">
<button class="btn" onclick="ingestRSS()">Fetch</button>
</div>
</div>
<div class="card">
<h3>GDELT Articles</h3>
<p class="sub" style="margin:0.5rem 0">Global news monitoring</p>
<button class="btn" onclick="ingestGDELT()">Fetch Latest</button>
</div>
<div class="card">
<h3>Earthquakes (USGS)</h3>
<p class="sub" style="margin:0.5rem 0">Last hour of seismic data</p>
<button class="btn" onclick="ingestEarthquakes()">Fetch Latest</button>
</div>
<div class="card">
<h3>NATS Consumer</h3>
<p class="sub" style="margin:0.5rem 0">Process pending messages</p>
<button class="btn" onclick="processNATS()">Process</button>
</div>
</div>
<div id="ingest-result" style="margin-top:1rem;color:var(--muted);font-size:0.9rem"></div>
</div>
<!-- API Keys -->
<div class="section" id="tab-keys" style="display:none">
<h2>API Keys</h2>
<p class="sub" style="margin-bottom:0.75rem">Keys used by ingest services (FIRMS, Gemini, Telegram, ...). Values are stored in Postgres and never shown again — only set/missing status and the last 4 chars are displayed.</p>
<div class="key-grid" id="keys-grid"></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 -->
<div class="section" id="search-results" style="display:none">
<h2>Search Results (<span id="search-total">0</span>)</h2>
<table>
<thead><tr><th>Time</th><th>Source</th><th>Title</th><th>Sentiment</th><th>Location</th></tr></thead>
<tbody id="search-body"></tbody>
</table>
</div>
</div>
<script src="/static/vendor/leaflet/leaflet.js"></script>
<script>
const API = '';
async function loadSummary() {
try {
const r = await fetch(`${API}/api/analytics/summary`);
const d = await r.json();
document.getElementById('total-events').textContent = d.total_events;
document.getElementById('events-24h').textContent = d.events_last_24h;
document.getElementById('active-sources').textContent = d.active_sources;
document.getElementById('open-alerts').textContent = d.open_alerts;
document.getElementById('tracked-entities').textContent = d.tracked_entities;
const s = d.sentiment;
const total = s.positive_count + s.neutral_count + s.negative_count || 1;
document.getElementById('sent-pos').style.width = ((s.positive_count/total)*100)+'%';
document.getElementById('sent-neu').style.width = ((s.neutral_count/total)*100)+'%';
document.getElementById('sent-neg').style.width = ((s.negative_count/total)*100)+'%';
document.getElementById('sent-detail').textContent = `+${s.positive_count} | ~${s.neutral_count} | -${s.negative_count} (avg: ${s.avg_score.toFixed(3)})`;
} catch(e) { console.error('Summary load failed', e); }
}
async function loadEvents() {
try {
const r = await fetch(`${API}/api/events?limit=30`);
const d = await r.json();
const tb = document.getElementById('events-body');
tb.innerHTML = d.map(e => `<tr>
<td>${new Date(e.ingested_at).toLocaleString()}</td>
<td>${e.source_type}</td>
<td>${(e.title||'').substring(0,80)}</td>
<td>${sentimentBadge(e.sentiment_label)}</td>
<td>${e.location_name || '-'}</td>
</tr>`).join('');
} catch(e) { console.error('Events load failed', e); }
}
async function loadAlerts() {
try {
const r = await fetch(`${API}/api/alerts?limit=20`);
const d = await r.json();
const tb = document.getElementById('alerts-body');
tb.innerHTML = d.map(a => `<tr>
<td>${new Date(a.created_at).toLocaleString()}</td>
<td><span class="badge badge-${a.severity}">${a.severity}</span></td>
<td>${a.alert_type}</td>
<td>${a.title}</td>
</tr>`).join('');
} catch(e) { console.error('Alerts load failed', e); }
}
async function loadEntities() {
try {
const r = await fetch(`${API}/api/entities?limit=20`);
const d = await r.json();
const tb = document.getElementById('entities-body');
tb.innerHTML = d.map(e => `<tr>
<td>${e.name}</td>
<td>${e.entity_type}</td>
<td>${e.event_count}</td>
<td>${new Date(e.last_seen).toLocaleString()}</td>
</tr>`).join('');
} catch(e) { console.error('Entities load failed', e); }
}
async function searchEvents() {
const q = document.getElementById('search-q').value.trim();
if (!q) return;
try {
const r = await fetch(`${API}/api/search`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({q, limit: 50})
});
const d = await r.json();
document.getElementById('search-total').textContent = d.total;
document.getElementById('search-results').style.display = 'block';
const tb = document.getElementById('search-body');
tb.innerHTML = d.events.map(e => `<tr>
<td>${new Date(e.ingested_at).toLocaleString()}</td>
<td>${e.source_type}</td>
<td>${(e.title||'').substring(0,80)}</td>
<td>${sentimentBadge(e.sentiment_label)}</td>
<td>${e.location_name || '-'}</td>
</tr>`).join('');
} catch(e) { console.error('Search failed', e); }
}
function sentimentBadge(label) {
if (!label) return '-';
const cls = {positive:'badge-positive',negative:'badge-negative',neutral:'badge-neutral'}[label]||'badge-neutral';
return `<span class="badge ${cls}">${label}</span>`;
}
// ── API Keys ────────────────────────────────────────────────────────────
// Mirrors the backend keystore registry for instant client-side format hints.
const KEY_PATTERNS = {
'FIRMS_MAP_KEY': /^[0-9a-fA-F]{32}$/,
'GEMINI_API_KEY': /^AIza[0-9A-Za-z_-]{35}$/,
'TELEGRAM_TOKEN': /^\d{8,10}:[0-9A-Za-z_-]{35}$/,
};
async function loadKeys() {
try {
const r = await fetch(`${API}/api/keys`);
const d = await r.json();
const grid = document.getElementById('keys-grid');
grid.innerHTML = d.map(k => {
const hint = k.validated && k.example ? `<div class="key-hint">expected: ${k.example}</div>` : '';
return `<div class="key-card ${k.set?'set':''}">
<div class="key-head">
<span class="key-name">${k.name}</span>
<span class="key-status ${k.set?'set':'missing'}">${k.set?'●':'○'} ${k.set ? (k.masked||'set') : 'missing'}</span>
</div>
<div class="key-desc">${k.description || 'Custom key (not in registry).'}${hint}</div>
<div class="key-form">
<input type="password" id="key-in-${k.name}" placeholder="Paste new value…" autocomplete="off" spellcheck="false">
<button class="btn" onclick="saveKey('${k.name}')">Save</button>
${k.set ? `<button class="btn btn-danger" onclick="clearKey('${k.name}')">Clear</button>` : ''}
</div>
<div class="key-msg" id="key-msg-${k.name}"></div>
</div>`;
}).join('');
} catch(e) { console.error('Keys load failed', e); }
}
function keyMsg(name, text, isErr) {
const el = document.getElementById('key-msg-'+name);
if (el) { el.textContent = text; el.className = 'key-msg ' + (isErr ? 'err' : 'ok'); }
}
async function saveKey(name) {
const input = document.getElementById('key-in-'+name);
const value = (input.value||'').trim();
if (!value) { keyMsg(name, 'Enter a value to save.', true); return; }
const pat = KEY_PATTERNS[name];
if (pat && !pat.test(value)) { keyMsg(name, 'Format check failed — see the expected format above.', true); return; }
try {
const r = await fetch(`${API}/api/keys/${encodeURIComponent(name)}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({value})
});
const d = await r.json().catch(() => ({}));
if (!r.ok) { keyMsg(name, d.detail || 'Save failed', true); return; }
input.value = '';
keyMsg(name, `Saved (${d.masked || 'set'}).`, false);
loadKeys();
} catch(e) { keyMsg(name, 'Network error.', true); }
}
async function clearKey(name) {
if (!confirm(`Delete ${name}? This cannot be undone.`)) return;
try {
const r = await fetch(`${API}/api/keys/${encodeURIComponent(name)}`, {method:'DELETE'});
if (!r.ok) { keyMsg(name, 'Clear failed.', true); return; }
keyMsg(name, 'Key removed.', false);
loadKeys();
} catch(e) { keyMsg(name, 'Network error.', true); }
}
function showTab(name) {
['recent','alerts','entities','ingest','keys','map'].forEach(t => {
document.getElementById('tab-'+t).style.display = t===name?'block':'none';
});
document.querySelectorAll('.tab-bar .btn').forEach((b,i) => {
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map'][i]===name);
});
if (name==='alerts') loadAlerts();
if (name==='entities') loadEntities();
if (name==='keys') loadKeys();
if (name==='map') initMap();
}
async function ingestRSS() {
const url = document.getElementById('rss-url').value;
const r = await fetch(`${API}/api/ingest/rss?feed_url=${encodeURIComponent(url)}`, {method:'POST'});
const d = await r.json();
document.getElementById('ingest-result').textContent = `RSS: ${d.items_ingested} items ingested`;
loadSummary(); loadEvents();
}
async function ingestGDELT() {
const r = await fetch(`${API}/api/ingest/gdelt?max_articles=50`, {method:'POST'});
const d = await r.json();
document.getElementById('ingest-result').textContent = `GDELT: ${d.articles_ingested} articles ingested`;
loadSummary(); loadEvents();
}
async function ingestEarthquakes() {
const r = await fetch(`${API}/api/ingest/earthquakes`, {method:'POST'});
const d = await r.json();
document.getElementById('ingest-result').textContent = `USGS: ${d.events_ingested} events ingested`;
loadSummary(); loadEvents();
}
async function processNATS() {
const r = await fetch(`${API}/api/ingest/process?batch_size=100`, {method:'POST'});
const d = await r.json();
document.getElementById('ingest-result').textContent = `NATS: ${d.processed} messages processed`;
loadSummary(); loadEvents();
}
async function checkHealth() {
try {
const r = await fetch(`${API}/api/health`);
const d = await r.json();
document.getElementById('health').textContent = 'healthy';
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
} catch(e) {
document.getElementById('health').textContent = 'unreachable';
document.getElementById('health').className = '';
}
}
// ── 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
loadSummary(); loadEvents(); checkHealth();
setInterval(() => { loadSummary(); loadEvents(); checkHealth(); }, 30000);
</script>
</body>
</html>