osint-dashboard/app/static/index.html
Sirius DevOps bd4197cb6c
Some checks failed
build-and-deploy / build (push) Has been cancelled
map: fix cameras vanishing after opening a camera popup and zooming
Root cause: Leaflet 1.9.4's Map.closePopup() never nulls map._popup (only
unbindPopup does), so the moveend guard 'if (map._popup) return' skipped
every overlay reload forever after the first popup was ever opened. Opening
one camera popup froze the marker layer: zoom out and the map kept showing
the stale zoomed-in subset (or nothing) until a full page refresh.

Track real popup state via popupopen/popupclose events instead, and close
the popup on user zoom/drag so the moveend reload always runs after
navigation; the autopan skip still protects the popup when it pans itself
into view.
2026-08-27 17:38:38 -04:00

1200 lines
66 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">
<link rel="stylesheet" href="/static/vendor/leaflet/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/leaflet/MarkerCluster.Default.css">
<style>
:root { --bg: #0f172a; --surface: #1e293b; --border: #334155; --text: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --green: #4ade80; --red: #f87171; --yellow: #fbbf24; }
* { margin: 0; padding: 0; box-sizing: border-box; }
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)); }
/* ── 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: 160px; 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 video.thumb, .cam-pop iframe.thumb { height: 180px; background: #000; }
.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; }
/* ── Camera marker clusters (neon green, matches the dots) ──── */
.marker-cluster-small, .marker-cluster-medium, .marker-cluster-large {
background-color: rgba(74, 222, 128, 0.22);
}
.marker-cluster-small div, .marker-cluster-medium div, .marker-cluster-large div {
background-color: rgba(16, 185, 129, 0.92);
color: #04150c;
font-weight: 700;
box-shadow: 0 0 14px rgba(74, 222, 128, 0.55), 0 0 2px rgba(15, 23, 42, 0.9);
}
.marker-cluster-small div { font-size: 10px; }
.marker-cluster-medium div { font-size: 11px; }
.marker-cluster-large div { font-size: 12px; }
/* ── News panel (scraper feed + exec summaries) ─────────────── */
.news-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-bottom: 0.9rem; flex-wrap: wrap; }
.news-refresh { display: flex; align-items: center; gap: 0.6rem; }
.news-updated { font-size: 0.72rem; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.news-summary {
position: relative; background: linear-gradient(160deg, rgba(56,189,248,0.10), rgba(30,41,59,0.6) 45%);
border: 1px solid rgba(56,189,248,0.35); border-radius: 12px; padding: 1rem 1.25rem 1.1rem;
box-shadow: 0 0 24px rgba(56,189,248,0.14), inset 0 0 32px rgba(56,189,248,0.05);
margin-bottom: 1.2rem; overflow: hidden;
}
.news-summary::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(120px 60px at 85% -10%, rgba(56,189,248,0.22), transparent 70%);
}
.ns-kicker { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); text-shadow: 0 0 12px rgba(56,189,248,0.6); display: flex; align-items: center; gap: 0.5rem; }
.ns-badge { font-size: 0.6rem; font-weight: 700; background: rgba(56,189,248,0.16); border: 1px solid rgba(56,189,248,0.4); border-radius: 9999px; padding: 0.12rem 0.5rem; color: var(--accent); letter-spacing: 0.08em; }
.ns-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; margin: 0.35rem 0 0.6rem; }
.ns-body { font-size: 0.92rem; line-height: 1.62; color: var(--text); white-space: pre-wrap; position: relative; z-index: 1; }
.ns-body h1, .ns-body h2, .ns-body h3, .ns-body h4 { font-size: 1rem; margin: 0.5rem 0 0.25rem; color: var(--accent); }
.ns-body ul, .ns-body ol { margin: 0.35rem 0 0.35rem 1.2rem; }
.ns-body li { margin-bottom: 0.2rem; }
.ns-body code { background: rgba(15,23,42,0.6); border: 1px solid var(--border); border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.85em; }
.ns-empty { color: var(--muted); font-size: 0.85rem; font-style: italic; }
.ns-loading { color: var(--muted); font-size: 0.85rem; }
.ns-error { color: var(--red); font-size: 0.85rem; }
.news-list-head { display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 0.4rem; }
.news-count { font-size: 0.7rem; color: var(--accent); font-family: ui-monospace, monospace; }
.news-list { max-height: 46vh; min-height: 120px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; background: rgba(15,23,42,0.35); }
.news-list::-webkit-scrollbar { width: 8px; }
.news-list::-webkit-scrollbar-track { background: rgba(30,41,59,0.4); border-radius: 8px; }
.news-list::-webkit-scrollbar-thumb { background: rgba(56,189,248,0.35); border-radius: 8px; }
.news-list::-webkit-scrollbar-thumb:hover { background: rgba(56,189,248,0.55); }
.news-item {
display: flex; align-items: center; gap: 0.75rem; padding: 0.55rem 0.9rem;
border-bottom: 1px solid rgba(51,65,85,0.5); text-decoration: none; color: var(--text); transition: background .15s, box-shadow .15s;
}
.news-item:last-child { border-bottom: none; }
.news-item:hover { background: rgba(56,189,248,0.07); box-shadow: inset 2px 0 0 var(--accent); }
.news-dom { font-size: 0.66rem; font-family: ui-monospace, monospace; color: var(--accent); background: rgba(56,189,248,0.1); border: 1px solid rgba(56,189,248,0.25); border-radius: 9999px; padding: 0.12rem 0.5rem; white-space: nowrap; flex-shrink: 0; max-width: 30%; overflow: hidden; text-overflow: ellipsis; }
.news-title { flex: 1; font-size: 0.87rem; line-height: 1.35; color: var(--text); word-break: break-word; }
.news-item:hover .news-title { color: var(--accent); }
.news-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; white-space: nowrap; flex-shrink: 0; }
.news-geo { color: var(--green); font-size: 0.8rem; flex-shrink: 0; text-shadow: 0 0 8px rgba(74,222,128,0.6); }
.news-loading, .news-error { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; }
.news-error { color: var(--red); }
.news-empty { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; font-style: italic; }
</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>
<button class="btn" onclick="showTab('news')">News</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 + 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. 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>
</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-reset" onclick="mapResetView()">Reset view</button>
<div class="map-hint" id="map-hint"></div>
</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>
<!-- News (scraper feed + executive summaries) -->
<div class="section" id="tab-news" style="display:none">
<div class="news-head">
<div>
<h2>Global News Feed</h2>
<p class="sub">Scraped hourly from ~257 RSS sources · latest executive summary pinned above</p>
</div>
<div class="news-refresh">
<span class="news-updated" id="news-updated"></span>
<button class="btn" id="news-refresh-btn" onclick="loadNews(true)">Refresh</button>
</div>
</div>
<!-- Executive summary (pinned) -->
<div class="news-summary" id="news-summary-wrap">
<div class="ns-kicker">Executive Summary <span class="ns-badge" id="ns-badge">LATEST</span></div>
<div class="ns-time" id="ns-time"></div>
<div class="ns-body" id="ns-body">
<div class="ns-empty">No executive summary yet — the Gemini summarizer is idle until <code>GEMINI_API_KEY</code> is set on the Pi.</div>
</div>
</div>
<!-- Scrolling headline list -->
<div class="news-list-head">
<span>Recent Headlines</span>
<span class="news-count" id="news-count">0 articles</span>
</div>
<div class="news-list" id="news-list">
<div class="news-loading">Loading feed…</div>
</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 src="/static/vendor/leaflet/leaflet.markercluster.js"></script>
<script src="/static/vendor/leaflet/leaflet.heat.js"></script>
<script src="/static/vendor/hls/hls.min.js"></script>
<script>
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','news'].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','news'][i]===name);
});
if (name==='alerts') loadAlerts();
if (name==='entities') loadEntities();
if (name==='keys') loadKeys();
if (name==='map') initMap();
if (name==='news') loadNews();
}
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 = '';
}
}
// ── News feed (scraper pipe + exec summaries) ─────────────────────────────
let newsInterval = null;
const NEWS_REFRESH_MS = 900000; // 15 min
let newsGeo = [];
let newsPinsLayer = null;
function newsEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function newsTimeAgo(ts) {
if (!ts) return '—';
const diff = Date.now() - new Date(ts).getTime();
if (diff < 60000) return `${Math.floor(diff/1000)}s ago`;
if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
return `${Math.floor(diff/86400000)}d ago`;
}
function newsSimpleMD(text) {
// Lightweight markdown→HTML for summary bodies (bold, headers, lists, paragraphs)
if (!text) return '';
let h = newsEsc(String(text));
// code blocks (already escaped so match escaped backticks)
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
// bold
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// headers (start of line # through ##)
h = h.replace(/^### (.+)$/gm, '<h4>$1</h4>');
h = h.replace(/^## (.+)$/gm, '<h3>$1</h3>');
h = h.replace(/^# (.+)$/gm, '<h2>$1</h2>');
// unordered lists
h = h.replace(/^[\*\-]\s(.+)$/gm, '<li>$1</li>');
h = h.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
// double-newline = paragraph
h = h.replace(/\n{2,}/g, '</p><p>');
// single newline = br
h = h.replace(/\n/g, '<br>');
return '<p>'+h+'</p>';
}
async function loadNews(force) {
document.getElementById('news-updated').textContent = 'updating…';
let articles = [];
let summaries = [];
try {
const [rArticles, rSumm] = await Promise.all([
fetch(`${API}/api/news?limit=100`),
fetch(`${API}/api/news/summaries?limit=1`)
]);
if (rArticles.ok) articles = await rArticles.json();
if (rSumm.ok) summaries = await rSumm.json();
} catch(e) {
document.getElementById('news-list').innerHTML = '<div class="news-error">Failed to load news feed: ' + newsEsc(e.message || e) + '</div>';
document.getElementById('ns-body').innerHTML = '<div class="ns-error">Failed to load summary: ' + newsEsc(e.message || e) + '</div>';
document.getElementById('news-updated').textContent = 'error';
console.error('News load failed', e);
return;
}
renderNewsSummary(summaries);
renderNewsList(articles);
// Geo-pin stretch: if articles carry lat/lon (future backend), collect + plot on GIBS map
newsGeo = articles.filter(a => typeof a.lat === 'number' && typeof a.lon === 'number');
if (newsGeo.length && map) {
renderNewsPins();
}
document.getElementById('news-updated').textContent = 'updated ' + new Date().toLocaleTimeString();
if (!newsInterval) {
newsInterval = setInterval(() => loadNews(false), NEWS_REFRESH_MS);
}
}
function renderNewsSummary(summaries) {
const bodyEl = document.getElementById('ns-body');
const timeEl = document.getElementById('ns-time');
const badgeEl = document.getElementById('ns-badge');
if (!summaries || !summaries.length) {
bodyEl.innerHTML = '<div class="ns-empty">No executive summary yet — the Gemini summarizer is idle until <code>GEMINI_API_KEY</code> is set on the Pi.</div>';
timeEl.textContent = '—';
badgeEl.textContent = 'WAITING';
return;
}
const s = summaries[0];
badgeEl.textContent = 'LATEST';
timeEl.textContent = s.batch_timestamp ? 'Batch: ' + new Date(s.batch_timestamp).toLocaleString() : '—';
bodyEl.innerHTML = newsSimpleMD(s.summary_text || '');
}
function renderNewsList(articles) {
const listEl = document.getElementById('news-list');
document.getElementById('news-count').textContent = articles.length + ' articles';
if (!articles || !articles.length) {
listEl.innerHTML = '<div class="news-empty">No articles scraped yet — the scraper runs hourly at :00. Check news-scraper logs.</div>';
return;
}
listEl.innerHTML = articles.map(a => {
const geoTag = a.lat != null && a.lon != null ? '<span class="news-geo" title="geotagged">📍</span>' : '';
return '<a class="news-item" href="' + newsEsc(a.url || '#') + '" target="_blank" rel="noopener">' +
'<span class="news-dom">' + newsEsc(a.domain || '?') + '</span>' +
'<span class="news-title">' + newsEsc((a.title||'').trim()) + '</span>' +
geoTag +
'<span class="news-time">' + newsTimeAgo(a.timestamp) + '</span>' +
'</a>';
}).join('');
}
function renderNewsPins() {
if (!map || !newsGeo.length) return;
if (newsPinsLayer) { map.removeLayer(newsPinsLayer); newsPinsLayer = null; }
newsPinsLayer = L.layerGroup(newsGeo.map(a => {
const icon = L.divIcon({
className: '',
html:'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f0abfc;box-shadow:0 0 10px #f0abfc,0 0 2px #0f172a;border:1px solid #0f172a;"></span>',
iconSize: [12, 12], iconAnchor: [6, 6],
});
return L.marker([a.lat, a.lon], { icon })
.bindPopup(`<div class="cam-pop"><b>${newsEsc(a.title||'Headline')}</b><br><a href="${newsEsc(a.url||'#')}" target="_blank" rel="noopener">read article →</a><br><span class="k">${newsEsc(a.domain||'')}</span></div>`);
}));
newsPinsLayer.addTo(map);
}
// ── Map (NASA GIBS basemap + FIRMS heatmap + open cameras) ───────────────
let map = null, mapLayer = null, mapInitStarted = false;
let firesHeat = null, camsGroup = null, firesOn = false, camsOn = false;
let firesOpacity = 0.65, firesColor = 'brightness', firesSince = '24h';
let camsOpacity = 1.0, baseOpacity = 1.0;
let camReq = 0, fireReq = 0; // stale-response guards (rapid zoom races)
let mapLayers = []; // catalog from /api/map/layers
let mapDomain = null; // time windows for current layer
let mapLatest = null; // ISO date of most recent imagery
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: false,
worldCopyJump: true,
minZoom: 1,
maxZoom: 12,
attributionControl: true,
});
L.control.zoom({ position: 'topright' }).addTo(map);
map.attributionControl.setPrefix('');
let activeHls = null;
map.on('popupopen', (e) => {
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
const root = e.popup.getElement();
const v = root && root.querySelector('video[data-hls]');
if (!v) return;
const fallback = () => {
const img = document.createElement('img');
img.className = 'thumb';
img.src = v.dataset.fallback;
img.alt = 'camera preview';
v.replaceWith(img);
};
if (window.Hls && Hls.isSupported()) {
activeHls = new Hls({ enableWorker: true, lowLatencyMode: true });
activeHls.loadSource(v.dataset.hls);
activeHls.attachMedia(v);
v.play && v.play().catch(() => {});
activeHls.on(Hls.Events.ERROR, (_, data) => {
if (data && data.fatal) fallback();
});
} else if (v.canPlayType && v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = v.dataset.hls;
v.addEventListener('error', fallback, { once: true });
} else {
fallback();
}
});
map.on('popupclose', () => {
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
});
updateHeatLegend();
// Reload overlays when the user pans/zooms. A live popup makes the
// rebuild skip (so the popup's autopan doesn't destroy it), but user
// zoom/drag must close the popup FIRST — otherwise zooming out with a
// camera popup open leaves the stale, zoomed-in marker group on the
// map and the cameras "vanish" until a refresh.
//
// NOTE: `map._popup` is NOT a reliable open-check in Leaflet 1.9.4 —
// Map.closePopup() never nulls it, so once any popup has been opened
// it stays truthy forever. Track the real state via popupopen/close.
let camPopupOpen = false;
map.on('popupopen', () => { camPopupOpen = true; });
map.on('popupclose', () => { camPopupOpen = false; });
map.on('zoomstart', () => { if (camPopupOpen) map.closePopup(); });
map.on('dragstart', () => { if (camPopupOpen) map.closePopup(); });
map.on('moveend', () => {
if (camPopupOpen) return; // only the popup's own autopan now
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);
}
}
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();
let west = b.getWest();
let east = b.getEast();
let south = Math.max(-90, Math.min(90, b.getSouth()));
let north = Math.max(-90, Math.min(90, b.getNorth()));
const lonSpan = east - west;
// World view, or a wrap that spans ~the whole globe.
if (map.getZoom() <= 3 || lonSpan >= 300) {
return '-180.0000,-85.0000,180.0000,85.0000';
}
// Normalize each edge into [-180, 180] WITHOUT clamping both to 180
// (that collapsed CA-on-a-wrapped-copy to an empty sliver).
const norm = (x) => ((x + 180) % 360 + 360) % 360 - 180;
west = norm(west);
east = norm(east);
if (west >= east) {
// Crosses the dateline: keep the latitude strip, don't drop the state.
return `-180.0000,${south.toFixed(4)},180.0000,${north.toFixed(4)}`;
}
return `${west.toFixed(4)},${south.toFixed(4)},${east.toFixed(4)},${north.toFixed(4)}`;
}
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 (~290400K) 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 (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;
const req = ++fireReq;
try {
let url = `${API}/api/fires?bbox=${currentBBox()}&limit=2000`;
const since = sinceToISO(firesSince);
if (since) url += `&since=${encodeURIComponent(since)}`;
const r = await fetch(url);
const fires = await r.json();
if (req !== fireReq) return; // superseded by a newer pan/zoom
if (firesHeat) map.removeLayer(firesHeat);
const pts = fires.map(f => [f.latitude, f.longitude, firesIntensity(f)]);
firesHeat = L.heatLayer(pts, {
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.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);
}
}
// ── 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function camSourceLink(c) {
const url = c.source_url || '';
if (!url) return '';
// Never emit an rtsp:// href — the OS opens VLC (or another player).
if (url.toLowerCase().startsWith('rtsp://')) {
const host = url.replace(/^rtsp:\/\//i, '').replace(/\/.*$/, '');
return `<span style="color:var(--muted)">RTSP ${esc(host)}:554</span>`;
}
return `<a href="${esc(url)}" target="_blank" rel="noopener">source page ↗</a>`;
}
function youtubeId(url) {
const m = String(url || '').match(/(?:youtu\.be\/|v=)([A-Za-z0-9_-]{6,})/);
return m ? m[1] : '';
}
function camKind(c) {
const u = String(c.snapshot_url || c.source_url || '').toLowerCase();
if (c.device_type === 'youtube' || u.includes('youtube.com') || u.includes('youtu.be')) return 'youtube';
if (c.device_type === 'hls' || u.includes('.m3u8')) return 'hls';
if (c.device_type === 'rtsp' || u.startsWith('rtsp://')) return 'rtsp';
return 'http';
}
function camThumb(c) {
if (!c.id) return '<div class="thumb placeholder">no preview available</div>';
const kind = camKind(c);
if (kind === 'youtube') {
const vid = youtubeId(c.snapshot_url || c.source_url);
if (!vid) return '<div class="thumb placeholder">youtube id missing</div>';
return `<iframe class="thumb" src="https://www.youtube-nocookie.com/embed/${esc(vid)}?autoplay=1&mute=1" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen loading="lazy"></iframe>`;
}
if (kind === 'hls') {
return `<video class="thumb" data-hls="/api/cameras/${esc(c.id)}/hls.m3u8" data-fallback="/api/cameras/${esc(c.id)}/stream" muted autoplay playsinline controls></video>`;
}
const first = kind === 'rtsp'
? `/api/cameras/${esc(c.id)}/snapshot`
: `/api/cameras/${esc(c.id)}/stream`;
const onerr = (
"if (!this.dataset.f) { this.dataset.f='1'; this.src='/api/cameras/" + esc(c.id) + "/snapshot'; } " +
"else { this.classList.add('placeholder'); this.onerror=null; this.alt='preview unavailable'; this.removeAttribute('src'); }"
);
return `<img class="thumb" src="${first}" alt="camera preview" loading="lazy" onerror="${onerr}">`;
}
async function loadCams() {
if (!map) return;
const req = ++camReq;
try {
const r = await fetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=5000`);
const cams = await r.json();
if (req !== camReq) return; // superseded by a newer pan/zoom
if (camsGroup) map.removeLayer(camsGroup);
// Clustered markers: camera coverage stays visible as numbered
// clusters at every zoom instead of vanishing into a sparse/empty
// viewport when zoomed in (and a 5000-dot soup when zoomed out).
camsGroup = L.markerClusterGroup({
maxClusterRadius: 50,
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
chunkedLoading: true,
chunkInterval: 100,
chunkDelay: 10,
});
cams.forEach(c => {
// Never render a phantom marker for a camera with no coords.
if (c.lat == null || c.lon == null) return;
const icon = L.divIcon({
className: '',
html: '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#4ade80;box-shadow:0 0 8px #4ade80,0 0 2px #0f172a;border:1px solid #0f172a;"></span>',
iconSize: [12, 12], iconAnchor: [6, 6],
});
camsGroup.addLayer(L.marker([c.lat, c.lon], { icon })
.bindPopup(`<div class="cam-pop">` +
`<b>${esc(c.location_name || 'Open camera')}</b>` +
`${c.id ? camThumb(c) : '<div class="thumb placeholder">no snapshot</div>'}` +
`<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>` +
camSourceLink(c) +
`</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.toLocaleString()} 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>