osint-dashboard/app/static/index.html
Sirius DevOps 20a266563c fix(aircraft): show planespotters photo in overlay popup
Planespotters 403s server clients whose User-Agent has no contact
email; compose default UA lacked one. Leaflet popup.update() also
re-ran the bindPopup factory and wiped the thumbnail after load.

- Send a contact UA on planespotters lookups; align compose default.
- Resize the popup without re-rendering content.
- Keep hex/src on live firefighter upserts so they get a photo too.
2026-08-28 23:28:28 -04:00

3242 lines
169 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 — Global Situational Awareness Terminal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="/static/vendor/leaflet/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/leaflet/MarkerCluster.Default.css">
<style>
/* ═══════════════════════════════════════════════════════════════
OSINT//DASHBOARD — "SECTION 9" GHOST-SHELL HUD THEME
Cyberpunk military-intel terminal. Near-black deep blue, cyan
primary, magenta secondary, chamfered panels, scanline chrome,
mono data readouts.
═══════════════════════════════════════════════════════════════ */
:root {
color-scheme: dark;
--bg-0: #030609; /* page floor */
--bg-1: #060b14; /* chrome */
--bg-2: #0a1322; /* panels */
--bg-3: #0e1a2e; /* raised */
--line: #14324a; /* borders */
--line-hi: #1e4a6e; /* hover borders */
--cyan: #35e0ff; /* primary accent */
--cyan-dim: #0e4a63;
--cyan-glow: rgba(53, 224, 255, 0.35);
--magenta: #ff2e97; /* secondary accent */
--amber: #ffb454;
--green: #53f0a5;
--red: #ff5d5d;
--text: #c8e6f2;
--muted: #5f7d95;
--topbar-h: 46px;
--dock-h: 62px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: 'Rajdhani', 'Segoe UI', system-ui, sans-serif;
background: var(--bg-0); color: var(--text);
overflow: hidden;
font-size: 15px; letter-spacing: 0.01em;
}
::selection { background: var(--cyan-dim); color: #eafcff; }
:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
a { color: var(--cyan); }
.mono, code, kbd { font-family: 'Share Tech Mono', ui-monospace, SFMono-Regular, Menlo, monospace; }
.btn {
background: linear-gradient(180deg, var(--bg-3), var(--bg-2));
border: 1px solid var(--line); color: var(--text);
border-radius: 4px; padding: 0.35rem 0.9rem; cursor: pointer;
font-family: 'Rajdhani', sans-serif; font-weight: 600;
font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase;
transition: border-color .15s, box-shadow .15s, color .15s;
}
.btn:hover { border-color: var(--cyan); color: var(--cyan); box-shadow: 0 0 10px var(--cyan-glow); }
.btn.active { background: var(--cyan); border-color: var(--cyan); color: #02131c; box-shadow: 0 0 14px var(--cyan-glow); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Chrome texture: scanlines for HUD surfaces ─────────────── */
.chrome {
background-image: repeating-linear-gradient(
0deg, rgba(53,224,255,0.028) 0 1px, transparent 1px 3px);
}
/* ═══════════════ TOP BAR ═══════════════ */
.topbar {
height: var(--topbar-h);
display: flex; align-items: center; justify-content: space-between;
gap: 1rem; padding: 0 1.1rem;
background: linear-gradient(180deg, #081120, var(--bg-1));
border-bottom: 1px solid var(--line);
position: relative; z-index: 900;
box-shadow: 0 0 24px rgba(53,224,255,0.08);
}
.topbar::after {
content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 1px;
background: linear-gradient(90deg, transparent, var(--cyan) 18%, transparent 60%);
opacity: 0.5;
}
.brand { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
.brand-mark {
font-family: 'Orbitron', sans-serif; color: var(--cyan);
font-size: 1.05rem; text-shadow: 0 0 12px var(--cyan-glow);
border: 1px solid var(--line-hi); padding: 0.1rem 0.34rem; border-radius: 3px;
background: rgba(53,224,255,0.06);
}
.brand-name {
font-family: 'Orbitron', sans-serif; font-weight: 700; font-size: 0.95rem;
color: #eafcff; letter-spacing: 0.14em; white-space: nowrap;
}
.brand-name .accent { color: var(--cyan); text-shadow: 0 0 10px var(--cyan-glow); }
.brand-sub {
font-family: 'Share Tech Mono', monospace; font-size: 0.58rem;
color: var(--muted); letter-spacing: 0.22em; text-transform: uppercase;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
@media (max-width: 900px) { .brand-sub { display: none; } }
.topbar-right { display: flex; align-items: center; gap: 1rem; }
.sys-status {
display: flex; align-items: center; gap: 0.5rem;
font-family: 'Share Tech Mono', monospace; font-size: 0.72rem;
color: var(--muted); white-space: nowrap;
}
.sys-status .sep { opacity: 0.35; }
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.dot.ok { background: var(--green); box-shadow: 0 0 8px var(--green); animation: pulse 2.4s infinite; }
.dot.bad { background: var(--red); box-shadow: 0 0 8px var(--red); }
.dot.dim { background: var(--muted); box-shadow: none; animation: none; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
.clock { color: var(--cyan); }
@media (max-width: 720px) { .sys-status .hide-sm { display: none; } }
/* ── Dropdown nav ── */
.nav { position: relative; }
.nav-btn {
display: flex; align-items: center; gap: 0.5rem;
background: linear-gradient(180deg, var(--bg-3), var(--bg-2));
border: 1px solid var(--line-hi); color: var(--text);
font-family: 'Orbitron', sans-serif; font-size: 0.72rem; font-weight: 700;
letter-spacing: 0.16em; padding: 0.42rem 0.85rem; border-radius: 4px;
cursor: pointer; transition: border-color .15s, box-shadow .15s;
}
.nav-btn:hover, .nav-btn[aria-expanded="true"] {
border-color: var(--cyan); box-shadow: 0 0 12px var(--cyan-glow);
}
.nav-caret { color: var(--cyan); transition: transform .18s; }
.nav-btn[aria-expanded="true"] .nav-caret { transform: rotate(180deg); }
.nav-menu {
position: absolute; right: 0; top: calc(100% + 6px);
min-width: 230px; background: var(--bg-2);
border: 1px solid var(--line-hi); border-radius: 6px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 18px rgba(53,224,255,0.12);
padding: 0.4rem; display: none; z-index: 950;
clip-path: polygon(0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px, 100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px));
}
.nav-menu.open { display: block; animation: menuIn .14s ease-out; }
@keyframes menuIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.nav-item {
display: flex; align-items: center; gap: 0.6rem; width: 100%;
background: none; border: none; color: var(--text); text-align: left;
font-family: 'Rajdhani', sans-serif; font-weight: 600; font-size: 0.88rem;
letter-spacing: 0.09em; text-transform: uppercase;
padding: 0.55rem 0.7rem; border-radius: 4px; cursor: pointer;
border-left: 2px solid transparent;
}
.nav-item:hover, .nav-item:focus-visible { background: rgba(53,224,255,0.08); border-left-color: var(--cyan); color: var(--cyan); }
.nav-item .ico { color: var(--cyan); font-size: 0.8rem; width: 1.1em; text-align: center; }
.nav-item.primary .ico { color: var(--magenta); }
.nav-sep { height: 1px; background: var(--line); margin: 0.35rem 0.3rem; }
/* ═══════════════ STAGE / VIEWS ═══════════════ */
.stage {
height: calc(100vh - var(--topbar-h) - var(--dock-h));
position: relative;
}
.stage.scroll { overflow-y: auto; }
.view { display: none; }
.view.active { display: block; }
/* ── MAP VIEW (landing) ── */
#view-map { height: 100%; }
.map-toolbar {
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
z-index: 500; display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
padding: 0.35rem 0.5rem; border-radius: 6px;
background: rgba(6,11,20,0.82); backdrop-filter: blur(6px);
border: 1px solid var(--line-hi);
box-shadow: 0 0 16px rgba(53,224,255,0.10);
clip-path: polygon(0 6px, 6px 0, calc(100% - 6px) 0, 100% 6px, 100% calc(100% - 6px), calc(100% - 6px) 100%, 6px 100%, 0 calc(100% - 6px));
}
.map-toolbar select, .map-toolbar input[type="date"], .map-toolbar input[type="datetime-local"] {
background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
border-radius: 4px; padding: 0.3rem 0.45rem; font-size: 0.76rem;
font-family: 'Share Tech Mono', monospace; color-scheme: dark;
}
.map-toolbar select:focus, .map-toolbar input:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 8px var(--cyan-glow); }
.map-toolbar .btn { padding: 0.3rem 0.6rem; font-size: 0.72rem; }
.mt-label { font-size: 0.6rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin-right: 0.15rem; }
.map-stage { position: relative; height: 100%; }
#map { width: 100%; height: 100%; z-index: 0; background: var(--bg-0); }
.map-stage::after { /* CRT vignette over the satellite globe */
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 400;
background: radial-gradient(120% 100% at 50% 45%, transparent 55%, rgba(3,6,9,0.55) 100%);
}
.leaflet-container { background: var(--bg-0); font-family: inherit; }
.leaflet-control-zoom a {
background: var(--bg-2) !important; color: var(--text) !important;
border-color: var(--line) !important;
}
.leaflet-control-zoom a:hover { background: var(--bg-3) !important; color: var(--cyan) !important; }
.leaflet-control-attribution {
background: rgba(3,6,9,0.78) !important; color: #8fa8bd !important;
font-family: 'Share Tech Mono', monospace !important; font-size: 10px !important;
}
.leaflet-control-attribution a { color: var(--cyan) !important; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip {
background: var(--bg-2) !important; color: var(--text) !important;
border: 1px solid var(--line-hi); box-shadow: 0 0 20px rgba(53,224,255,0.15) !important;
border-radius: 6px;
}
.leaflet-popup-content-wrapper::before {
content: ''; position: absolute; inset: 0; pointer-events: none;
background-image: repeating-linear-gradient(0deg, rgba(53,224,255,0.03) 0 1px, transparent 1px 3px);
}
.leaflet-popup-content-wrapper a { color: var(--cyan); }
.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)); }
.leaflet-div-icon { background: transparent; border: none; }
/* ── Heading-aware live-feed glyph markers (aircraft/trains/vessels) ── */
.hdg-marker {
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
}
.hdg-glyph {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px;
will-change: transform;
filter: drop-shadow(0 0 4px currentColor) drop-shadow(0 1px 2px rgba(0,0,0,0.8));
}
.hdg-glyph svg { width: 100%; height: 100%; display: block; }
/* ── Camera marker clusters (neon green, matches the dots) ──── */
.marker-cluster-small, .marker-cluster-medium, .marker-cluster-large {
background-color: rgba(74, 222, 128, 0.22);
}
.marker-cluster-small div, .marker-cluster-medium div, .marker-cluster-large div {
background-color: rgba(16, 185, 129, 0.92);
color: #04150c;
font-weight: 700;
box-shadow: 0 0 14px rgba(74, 222, 128, 0.55), 0 0 2px rgba(3, 6, 9, 0.9);
}
.marker-cluster-small div { font-size: 10px; }
.marker-cluster-medium div { font-size: 11px; }
.marker-cluster-large div { font-size: 12px; }
/* ── Map hint + HUD chips ── */
.map-hint {
position: absolute; left: 12px; bottom: 12px; z-index: 500;
font-family: 'Share Tech Mono', monospace; font-size: 0.68rem; color: var(--muted);
background: rgba(6,11,20,0.72); border: 1px solid var(--line);
border-radius: 4px; padding: 0.3rem 0.6rem; max-width: 60%;
text-shadow: 0 0 6px rgba(0,0,0,0.8);
}
.hud-chips {
position: absolute; right: 12px; bottom: 12px; z-index: 500;
display: flex; flex-direction: column; gap: 0.35rem; align-items: flex-end;
}
.hud-chip {
display: flex; align-items: center; gap: 0.45rem;
font-family: 'Share Tech Mono', monospace; font-size: 0.66rem; color: var(--muted);
background: rgba(6,11,20,0.78); border: 1px solid var(--line);
border-radius: 4px; padding: 0.26rem 0.55rem; letter-spacing: 0.06em;
backdrop-filter: blur(4px);
}
.hud-chip b { color: var(--text); font-weight: 400; }
.hud-chip .c { width: 6px; height: 6px; border-radius: 50%; }
.hud-chip .c.fires { background: #fb923c; box-shadow: 0 0 6px #fb923c; }
.hud-chip .c.cams { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
.hud-chip .c.blips { background: var(--magenta); box-shadow: 0 0 6px var(--magenta); }
.hud-chip .c.news { background: var(--magenta); box-shadow: 0 0 6px var(--magenta); border-radius: 1px; transform: rotate(45deg); }
.hud-chip.off { opacity: 0.4; }
#dvr-bar {
position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%);
z-index: 520; display: flex; align-items: center; gap: 0.55rem;
background: rgba(6,11,20,0.86); border: 1px solid var(--line-hi);
border-radius: 4px; padding: 0.28rem 0.7rem;
font-family: 'Share Tech Mono', monospace; font-size: 0.66rem; color: var(--muted);
min-width: 280px; max-width: 52%;
}
#dvr-bar input[type=range] { flex: 1; accent-color: var(--cyan); }
#dvr-bar.live #dvr-live { color: var(--green); text-shadow: 0 0 8px rgba(83,240,165,0.5); }
#dvr-toast {
position: absolute; top: 12px; right: 12px; z-index: 700;
max-width: 280px; pointer-events: none;
}
#dvr-toast .gf-alert {
background: rgba(255,46,151,0.16); border: 1px solid var(--magenta);
color: var(--text); font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem; padding: 0.4rem 0.6rem; margin-bottom: 0.35rem;
border-radius: 4px;
}
/* ── Layer control panel (dark HUD) ── */
#layer-panel {
position: absolute; top: 58px; left: 12px; z-index: 600;
width: 268px; max-height: calc(100% - 74px); overflow: hidden;
background: rgba(6,11,20,0.86); backdrop-filter: blur(8px);
border: 1px solid var(--line-hi); border-radius: 6px;
box-shadow: 0 0 22px rgba(53,224,255,0.14), 0 0 2px rgba(53,224,255,0.4);
color: var(--text); font-size: 0.78rem; display: flex; flex-direction: column;
clip-path: polygon(0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px, 100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px));
}
.lp-head {
display: flex; justify-content: space-between; align-items: center;
padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--line);
font-family: 'Orbitron', sans-serif; font-size: 0.68rem; font-weight: 700;
letter-spacing: 0.14em; text-transform: uppercase; color: var(--cyan);
text-shadow: 0 0 10px rgba(53,224,255,0.55); cursor: pointer; user-select: none;
background: linear-gradient(180deg, rgba(53,224,255,0.07), transparent);
}
.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.75rem 0.65rem; display: flex; flex-direction: column; gap: 0.5rem; overflow-y: auto; }
#layer-panel.collapsed .lp-body { display: none; }
.lp-layer { display: flex; flex-direction: column; gap: 0.28rem; padding-bottom: 0.45rem; border-bottom: 1px dashed var(--line); }
.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.05em; font-size: 0.68rem; }
.lp-name input[type="checkbox"] { accent-color: var(--cyan); cursor: pointer; }
.lp-name .soon { font-size: 0.55rem; color: var(--amber); border: 1px solid rgba(255,180,84,0.4); border-radius: 3px; padding: 0 0.3rem; letter-spacing: 0.1em; }
.lp-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.lp-dot.base { background: #35e0ff; box-shadow: 0 0 7px #35e0ff; }
.lp-dot.fires { background: #fb923c; box-shadow: 0 0 7px #fb923c; }
.lp-dot.cams { background: #4ade80; box-shadow: 0 0 7px #4ade80; }
.lp-dot.blips { background: var(--magenta); box-shadow: 0 0 7px var(--magenta); }
.lp-dot.news { background: var(--magenta); box-shadow: 0 0 7px var(--magenta); border-radius: 1px; transform: rotate(45deg); }
.lp-dot.weather { background: var(--amber); box-shadow: 0 0 7px var(--amber); }
.lp-dot.flights { background: #7dd3fc; box-shadow: 0 0 7px #7dd3fc; }
.lp-dot.vessels { background: #2dd4bf; box-shadow: 0 0 7px #2dd4bf; }
.lp-dot.radar { background: #38bdf8; box-shadow: 0 0 7px #38bdf8; }
.lp-dot.thermal { background: #f97316; box-shadow: 0 0 7px #f97316; }
.lp-dot.wfigs { background: #ef4444; box-shadow: 0 0 7px #ef4444; }
.lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; }
.lp-dot.storms { background: #f472b6; box-shadow: 0 0 7px #f472b6; }
.lp-count { font-family: 'Share Tech Mono', monospace; font-size: 0.7rem; color: var(--cyan); }
.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.62rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.lp-opacity input[type="range"] { flex: 1; accent-color: var(--cyan); height: 4px; cursor: pointer; }
.lp-opacity b { color: var(--text); font-family: 'Share Tech Mono', monospace; font-weight: 400; min-width: 2.4em; text-align: right; }
.lp-select { background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 4px; padding: 0.22rem 0.35rem; font-size: 0.68rem; font-family: 'Share Tech Mono', monospace; color-scheme: dark; }
.lp-select:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 8px var(--cyan-glow); }
.lp-legend { border-top: 1px solid var(--line); padding-top: 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; }
.lp-legend h4 { font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); margin: 0; font-family: 'Rajdhani', sans-serif; font-weight: 600; }
.lp-ramp { height: 8px; border-radius: 4px; background: linear-gradient(90deg, #2563eb, #38bdf8, #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.58rem; color: var(--muted); }
.lp-heat-legend { display: flex; align-items: center; justify-content: space-between; font-size: 0.64rem; 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-future { opacity: 0.5; }
.lp-future .lp-name { cursor: not-allowed; }
.lp-error { color: var(--red); font-size: 0.66rem; }
.lp-note { font-size: 0.6rem; color: var(--muted); opacity: 0.85; line-height: 1.35; }
/* ── Camera / blip popup thumbnails ── */
.cam-pop { min-width: 210px; max-width: 260px; }
.cam-pop .thumb { width: 100%; height: 160px; object-fit: cover; border-radius: 4px; border: 1px solid var(--line); margin: 0.3rem 0; box-shadow: 0 0 10px rgba(53,224,255,0.2); background: var(--bg-0); }
.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.6rem; letter-spacing: 0.05em; white-space: nowrap; width: 34%; font-family: 'Share Tech Mono', monospace; }
.cam-pop a { color: var(--cyan); word-break: break-all; }
.cam-pop .ps-photo { margin-top: 0.45rem; }
.cam-pop .ps-photo a.ps-link { display: block; }
.cam-pop .ps-thumb {
width: 100%; height: auto; max-height: 220px; object-fit: contain;
border-radius: 4px; border: 1px solid var(--line); background: var(--bg-0);
display: block; box-shadow: 0 0 10px rgba(53,224,255,0.2);
}
.cam-pop .ps-credit { font-size: 0.68rem; color: var(--muted); margin-top: 0.2rem; }
.cam-pop .ps-credit a { color: var(--cyan); }
.cam-pop .ps-loading { font-size: 0.68rem; color: var(--muted); margin-top: 0.35rem; }
.role-badge {
display: inline-block; font-family: 'Share Tech Mono', monospace;
font-size: 0.58rem; font-weight: 700; letter-spacing: 0.12em;
padding: 0.08rem 0.4rem; border-radius: 3px; margin-left: 0.4rem;
vertical-align: middle;
}
.role-badge.military { color: #f472b6; border: 1px solid #f472b6; background: rgba(244,114,182,0.12); }
.role-badge.civilian { color: #7dd3fc; border: 1px solid #38bdf8; background: rgba(56,189,248,0.1); }
.role-badge.government { color: #facc15; border: 1px solid #facc15; background: rgba(250,204,21,0.12); }
.role-badge.firefighter { color: #fb923c; border: 1px solid #fb923c; background: rgba(251,146,60,0.12); }
.blip-pop { min-width: 200px; max-width: 260px; }
.blip-pop .blip-src { font-family: 'Share Tech Mono', monospace; font-size: 0.62rem; color: var(--cyan); text-transform: uppercase; letter-spacing: 0.08em; }
.blip-pop .blip-time { font-size: 0.7rem; color: var(--muted); font-family: 'Share Tech Mono', monospace; margin: 0.2rem 0 0.3rem; }
/* ── Sub-views (News / Events / Alerts / ... ) ── */
.subview {
padding: 1.2rem clamp(1rem, 3vw, 2rem) 2.2rem;
max-width: 1280px; margin: 0 auto;
}
.subview > h2 {
font-family: 'Orbitron', sans-serif; font-size: 0.95rem; font-weight: 700;
letter-spacing: 0.16em; text-transform: uppercase; color: var(--text);
display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.35rem;
}
.subview > h2 .tick { color: var(--cyan); text-shadow: 0 0 10px var(--cyan-glow); }
.subview > .sub { font-size: 0.85rem; color: var(--muted); margin-bottom: 1.1rem; }
.panel {
background: linear-gradient(180deg, rgba(14,26,46,0.65), rgba(10,19,34,0.8));
border: 1px solid var(--line); border-radius: 6px; padding: 1rem 1.1rem;
box-shadow: 0 0 18px rgba(53,224,255,0.05);
}
.panel h3 {
font-size: 0.68rem; font-family: 'Orbitron', sans-serif; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.16em; color: var(--muted);
margin-bottom: 0.6rem;
}
table { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
th, td { text-align: left; padding: 0.5rem 0.65rem; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; font-family: 'Rajdhani', sans-serif; }
tr:hover td { background: rgba(53,224,255,0.04); }
.badge { display: inline-block; padding: 0.12rem 0.5rem; border-radius: 3px; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; }
.badge-positive { background: rgba(83,240,165,0.12); color: var(--green); border: 1px solid rgba(83,240,165,0.35); }
.badge-negative { background: rgba(255,93,93,0.12); color: var(--red); border: 1px solid rgba(255,93,93,0.35); }
.badge-neutral { background: rgba(95,125,149,0.15); color: var(--muted); border: 1px solid var(--line); }
.badge-high, .badge-critical { background: rgba(255,93,93,0.15); color: var(--red); border: 1px solid rgba(255,93,93,0.4); }
.badge-medium { background: rgba(255,180,84,0.12); color: var(--amber); border: 1px solid rgba(255,180,84,0.35); }
.badge-low { background: rgba(83,240,165,0.1); color: var(--green); border: 1px solid rgba(83,240,165,0.3); }
/* ── Stats strip (Events view) ── */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.8rem; margin-bottom: 1.1rem; }
.stat-card { position: relative; overflow: hidden; }
.stat-card .value { font-family: 'Orbitron', sans-serif; font-size: 1.5rem; font-weight: 700; color: var(--text); letter-spacing: 0.04em; }
.stat-card .value.cyan { color: var(--cyan); text-shadow: 0 0 14px var(--cyan-glow); }
.stat-card .value.red { color: var(--red); text-shadow: 0 0 14px rgba(255,93,93,0.4); }
.stat-card::after {
content: ''; position: absolute; top: 0; right: 0; width: 22px; height: 22px;
border-top: 1px solid var(--cyan); border-right: 1px solid var(--cyan); opacity: 0.5;
}
.sentiment-bar { display: flex; height: 18px; border-radius: 3px; overflow: hidden; margin-top: 0.5rem; border: 1px solid var(--line); }
.sentiment-bar div { transition: width 0.4s; }
.search-box { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.search-box input {
flex: 1; background: var(--bg-1); border: 1px solid var(--line);
border-radius: 4px; padding: 0.55rem 0.9rem; color: var(--text);
font-size: 0.88rem; font-family: 'Rajdhani', sans-serif;
}
.search-box input:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 10px var(--cyan-glow); }
/* ── News view ── */
.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.7rem; color: var(--muted); font-family: 'Share Tech Mono', monospace; }
.news-summary {
position: relative; background: linear-gradient(160deg, rgba(53,224,255,0.10), rgba(10,19,34,0.7) 45%);
border: 1px solid rgba(53,224,255,0.35); border-radius: 8px; padding: 1rem 1.25rem 1.1rem;
box-shadow: 0 0 26px rgba(53,224,255,0.12), inset 0 0 32px rgba(53,224,255,0.04);
margin-bottom: 1.2rem; overflow: hidden;
}
.news-summary::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(140px 70px at 85% -10%, rgba(53,224,255,0.18), transparent 70%);
}
.ns-kicker { font-family: 'Orbitron', sans-serif; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--cyan); text-shadow: 0 0 12px rgba(53,224,255,0.6); display: flex; align-items: center; gap: 0.5rem; }
.ns-badge { font-size: 0.58rem; font-weight: 700; background: rgba(53,224,255,0.14); border: 1px solid rgba(53,224,255,0.4); border-radius: 3px; padding: 0.1rem 0.45rem; color: var(--cyan); letter-spacing: 0.1em; font-family: 'Share Tech Mono', monospace; }
.ns-time { font-size: 0.68rem; color: var(--muted); font-family: 'Share Tech Mono', monospace; margin: 0.35rem 0 0.6rem; }
.ns-body { font-size: 0.9rem; 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: 0.98rem; margin: 0.5rem 0 0.25rem; color: var(--cyan); font-family: 'Rajdhani', sans-serif; }
.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(3,6,9,0.6); border: 1px solid var(--line); border-radius: 3px; padding: 0.05rem 0.3rem; font-size: 0.85em; font-family: 'Share Tech Mono', monospace; }
.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-family: 'Orbitron', sans-serif; font-size: 0.68rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); margin-bottom: 0.4rem; }
.news-count { font-size: 0.66rem; color: var(--cyan); font-family: 'Share Tech Mono', monospace; }
.news-list { max-height: 52vh; min-height: 120px; overflow-y: auto; border: 1px solid var(--line); border-radius: 6px; background: rgba(6,11,20,0.4); }
.news-list::-webkit-scrollbar { width: 8px; }
.news-list::-webkit-scrollbar-track { background: rgba(14,26,46,0.4); border-radius: 8px; }
.news-list::-webkit-scrollbar-thumb { background: rgba(53,224,255,0.3); border-radius: 8px; }
.news-list::-webkit-scrollbar-thumb:hover { background: rgba(53,224,255,0.5); }
.news-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.55rem 0.9rem; border-bottom: 1px solid rgba(20,50,74,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(53,224,255,0.06); box-shadow: inset 2px 0 0 var(--cyan); }
.news-dom { font-size: 0.62rem; font-family: 'Share Tech Mono', monospace; color: var(--cyan); background: rgba(53,224,255,0.08); border: 1px solid rgba(53,224,255,0.25); border-radius: 3px; padding: 0.1rem 0.45rem; white-space: nowrap; flex-shrink: 0; max-width: 30%; overflow: hidden; text-overflow: ellipsis; }
.news-title { flex: 1; font-size: 0.86rem; line-height: 1.35; color: var(--text); word-break: break-word; }
.news-item:hover .news-title { color: var(--cyan); }
.news-time { font-size: 0.68rem; color: var(--muted); font-family: 'Share Tech Mono', 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(83,240,165,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; }
/* ── Key cards (API Keys view) ── */
.key-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
.key-card { background: linear-gradient(180deg, rgba(14,26,46,0.65), rgba(10,19,34,0.8)); border: 1px solid var(--line); border-radius: 6px; padding: 1rem 1.1rem; box-shadow: 0 0 16px rgba(53,224,255,0.05); transition: box-shadow .2s, border-color .2s; }
.key-card:hover { box-shadow: 0 0 22px rgba(53,224,255,0.12); }
.key-card.set { border-color: rgba(83,240,165,0.45); box-shadow: 0 0 16px rgba(83,240,165,0.12); }
.key-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; }
.key-name { font-family: 'Share Tech Mono', monospace; font-size: 0.86rem; font-weight: 600; color: var(--cyan); word-break: break-all; }
.key-status { font-size: 0.68rem; font-family: 'Share Tech Mono', monospace; padding: 0.18rem 0.55rem; border-radius: 3px; white-space: nowrap; }
.key-status.set { background: rgba(83,240,165,0.1); color: var(--green); box-shadow: 0 0 10px rgba(83,240,165,0.3); }
.key-status.missing { background: rgba(255,93,93,0.1); color: var(--red); box-shadow: 0 0 10px rgba(255,93,93,0.2); }
.key-desc { font-size: 0.8rem; color: var(--muted); margin-bottom: 0.7rem; }
.key-hint { font-size: 0.7rem; color: var(--muted); opacity: 0.8; margin-top: 0.15rem; font-family: 'Share Tech Mono', monospace; }
.key-form { display: flex; gap: 0.5rem; }
.key-form input { flex: 1; background: var(--bg-0); border: 1px solid var(--line); border-radius: 4px; padding: 0.5rem 0.6rem; color: var(--text); font-size: 0.82rem; font-family: 'Share Tech Mono', monospace; }
.key-form input:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 8px var(--cyan-glow); }
.key-form .btn { white-space: nowrap; }
.key-form .btn-danger { border-color: rgba(255,93,93,0.5); color: var(--red); }
.key-form .btn-danger:hover { background: rgba(255,93,93,0.1); }
.key-msg { margin-top: 0.5rem; font-size: 0.78rem; min-height: 1rem; }
.key-msg.ok { color: var(--green); }
.key-msg.err { color: var(--red); }
/* ── Settings view ── */
.set-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 1rem; align-items: start; }
.set-row { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.9rem; }
.set-row label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); font-weight: 600; }
.set-row textarea, .set-row input[type="text"], .set-row select {
background: var(--bg-0); color: var(--text); border: 1px solid var(--line);
border-radius: 4px; padding: 0.5rem 0.6rem; font-size: 0.85rem;
font-family: 'Share Tech Mono', monospace; color-scheme: dark;
}
.set-row textarea:focus, .set-row input:focus, .set-row select:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 8px var(--cyan-glow); }
.set-note { font-size: 0.72rem; color: var(--muted); line-height: 1.5; }
.set-note code { background: rgba(3,6,9,0.7); border: 1px solid var(--line); border-radius: 3px; padding: 0.05rem 0.35rem; font-size: 0.82em; }
.set-actions { display: flex; gap: 0.6rem; margin-top: 0.4rem; }
.set-saved { color: var(--green); font-size: 0.75rem; font-family: 'Share Tech Mono', monospace; min-height: 1.1rem; }
.sys-row { display: flex; justify-content: space-between; padding: 0.32rem 0; border-bottom: 1px dashed var(--line); font-size: 0.8rem; }
.sys-row:last-child { border-bottom: none; }
.sys-row .k { color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.68rem; }
.sys-row .v { font-family: 'Share Tech Mono', monospace; }
/* ═══════════════ BOTTOM DOCK: tickers ═══════════════ */
.dock {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 900;
height: var(--dock-h); display: flex; flex-direction: column;
border-top: 1px solid var(--line);
background: linear-gradient(180deg, var(--bg-1), #050a13);
box-shadow: 0 -6px 30px rgba(0,0,0,0.5);
}
.ticker { display: flex; align-items: stretch; height: 50%; min-height: 0; }
.ticker + .ticker { border-top: 1px solid rgba(20,50,74,0.6); }
.tick-label {
display: flex; align-items: center; justify-content: center; gap: 0.4rem;
min-width: 74px; padding: 0 0.7rem; flex-shrink: 0;
font-family: 'Orbitron', sans-serif; font-size: 0.62rem; font-weight: 700;
letter-spacing: 0.2em; color: #02131c; background: var(--cyan);
text-shadow: none; position: relative;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 100%, 0 100%);
}
.ticker.news .tick-label { background: var(--magenta); color: #1c0311; }
.tick-label .st { animation: pulse 1.6s infinite; }
.tick-wrap { flex: 1; overflow: hidden; position: relative; display: flex; align-items: center;
mask-image: linear-gradient(90deg, transparent 0, #000 26px, #000 calc(100% - 26px), transparent 100%);
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 26px, #000 calc(100% - 26px), transparent 100%);
}
.tick-track { display: flex; width: max-content; animation: marquee 60s linear infinite; }
.ticker.news .tick-track { animation-duration: 90s; }
.tick-wrap:hover .tick-track, .tick-wrap:focus-within .tick-track { animation-play-state: paused; }
@keyframes marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.tick-group { display: flex; align-items: center; }
.tick-item {
display: inline-flex; align-items: center; gap: 0.5rem; white-space: nowrap;
font-size: 0.82rem; padding: 0 1rem; color: var(--text); text-decoration: none;
font-weight: 500;
}
.tick-item .sym { font-family: 'Share Tech Mono', monospace; color: var(--cyan); font-size: 0.74rem; }
.tick-item .price { font-family: 'Share Tech Mono', monospace; color: var(--text); }
.tick-item .chg.up { color: var(--green); }
.tick-item .chg.down { color: var(--red); }
.tick-item .chg.flat { color: var(--muted); }
.tick-item .dom { font-family: 'Share Tech Mono', monospace; font-size: 0.64rem; color: var(--cyan); background: rgba(53,224,255,0.08); border: 1px solid rgba(53,224,255,0.22); border-radius: 3px; padding: 0.05rem 0.4rem; }
.tick-item .tt { font-family: 'Share Tech Mono', monospace; font-size: 0.66rem; color: var(--muted); }
.tick-item .sep { color: var(--line-hi); }
button.tick-item { cursor: pointer; background: transparent; border: 0; font: inherit; color: inherit; }
.tick-item.brief {
cursor: pointer; background: linear-gradient(90deg, rgba(255,46,151,0.12), transparent 70%);
border-left: 2px solid var(--magenta); font-weight: 600;
}
.tick-item .b-tag { font-family: 'Share Tech Mono', monospace; font-size: 0.6rem; color: #1c0311; background: var(--magenta); border-radius: 2px; padding: 0.08rem 0.4rem; letter-spacing: 0.1em; }
.tick-item .b-tag.critical { background: var(--red); }
.tick-item .b-tag.high { background: var(--amber); }
.tick-item.brief:hover { color: var(--magenta); }
.tick-item.standby { color: var(--muted); opacity: 0.75; }
.tick-item.standby .price { color: var(--muted); }
.tick-sep { color: var(--line-hi); }
.tick-tag {
display: flex; align-items: center; flex-shrink: 0; padding: 0 0.8rem;
font-family: 'Share Tech Mono', monospace; font-size: 0.62rem;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted);
border-left: 1px solid var(--line);
}
.tick-tag.live { color: var(--green); text-shadow: 0 0 8px rgba(83,240,165,0.5); }
.tick-tag.standby { color: var(--amber); text-shadow: 0 0 8px rgba(255,180,84,0.4); animation: pulse 2.2s infinite; }
.tick-tag.err { color: var(--red); }
/* ── Boot overlay ── */
.boot {
position: fixed; inset: 0; z-index: 9999; background: var(--bg-0);
display: flex; align-items: center; justify-content: center;
transition: opacity .5s ease; pointer-events: auto; cursor: pointer;
}
.boot.gone { opacity: 0; pointer-events: none; }
.boot-inner { text-align: center; }
.boot-mark { font-family: 'Orbitron', sans-serif; font-size: 2.2rem; color: var(--cyan); text-shadow: 0 0 24px var(--cyan-glow); margin-bottom: 0.6rem; }
.boot-title { font-family: 'Orbitron', sans-serif; font-size: 1.3rem; font-weight: 900; letter-spacing: 0.3em; color: #eafcff; }
.boot-title span { color: var(--cyan); }
.boot-sub { font-family: 'Share Tech Mono', monospace; font-size: 0.66rem; color: var(--muted); letter-spacing: 0.3em; margin-top: 0.5rem; }
.boot-bar { width: 220px; height: 3px; margin: 1.1rem auto 0; background: var(--bg-2); border-radius: 2px; overflow: hidden; }
.boot-bar i { display: block; height: 100%; width: 40%; background: linear-gradient(90deg, transparent, var(--cyan), transparent); animation: bootSweep 0.9s linear infinite; }
@keyframes bootSweep { from { transform: translateX(-100%); } to { transform: translateX(350%); } }
@media (max-width: 820px) {
:root { --topbar-h: 42px; --dock-h: 56px; }
.tick-label { min-width: 56px; padding: 0 0.5rem; }
.tick-tag { display: none; }
.hud-chips { display: none; }
.map-hint { max-width: 80%; }
}
@media (prefers-reduced-motion: reduce) {
.tick-track { animation: none; }
.dot, .tick-label .st, .tick-tag.standby { animation: none; }
.nav-menu.open { animation: none; }
.boot-bar i { animation: none; }
}
</style>
</head>
<body>
<!-- Boot splash (one-time, click to skip) -->
<div id="boot" class="boot" role="presentation" aria-hidden="true">
<div class="boot-inner">
<div class="boot-mark">◢◤</div>
<div class="boot-title">OSINT<span>//</span>DASHBOARD</div>
<div class="boot-sub">SECURE TERMINAL INITIALIZING</div>
<div class="boot-bar"><i></i></div>
</div>
</div>
<!-- ═════════════ TOP BAR ═════════════ -->
<header class="topbar chrome">
<div class="brand">
<span class="brand-mark">◢◤</span>
<div>
<div class="brand-name">OSINT<span class="accent">//</span>DASHBOARD</div>
<div class="brand-sub">GLOBAL SITUATIONAL AWARENESS TERMINAL</div>
</div>
</div>
<div class="topbar-right">
<div class="sys-status">
<span id="health-dot" class="dot dim"></span>
<span id="health-text">BOOT…</span>
<span class="sep">|</span>
<span class="clock"><span id="utc-clock">--:--:--</span> UTC</span>
<span class="sep hide-sm">|</span>
<span class="hide-sm" id="last-update"></span>
</div>
<div class="nav">
<button id="nav-btn" class="nav-btn" aria-haspopup="true" aria-expanded="false">
MENU <span class="nav-caret"></span>
</button>
<div id="nav-menu" class="nav-menu" role="menu" aria-label="Application navigation">
<button class="nav-item primary" role="menuitem" data-view="map"><span class="ico"></span>Live Map</button>
<button class="nav-item primary" role="menuitem" data-view="news"><span class="ico"></span>News Brief</button>
<div class="nav-sep" role="separator"></div>
<button class="nav-item" role="menuitem" data-view="events"><span class="ico"></span>Events</button>
<button class="nav-item" role="menuitem" data-view="alerts"><span class="ico"></span>Alerts</button>
<button class="nav-item" role="menuitem" data-view="entities"><span class="ico"></span>Entities</button>
<button class="nav-item" role="menuitem" data-view="ingest"><span class="ico"></span>Ingest</button>
<button class="nav-item" role="menuitem" data-view="keys"><span class="ico"></span>API Keys</button>
<div class="nav-sep" role="separator"></div>
<button class="nav-item" role="menuitem" data-view="settings"><span class="ico"></span>Settings</button>
</div>
</div>
</div>
</header>
<!-- ═════════════ STAGE ═════════════ -->
<main class="stage" id="stage">
<!-- ───────────── VIEW: LIVE MAP (landing) ───────────── -->
<section class="view active" id="view-map" aria-label="Live map">
<div class="map-toolbar">
<span class="mt-label">Layer</span>
<select id="map-layer" onchange="mapLayerChanged()" aria-label="Satellite basemap layer"></select>
<span class="mt-label" id="map-time-label" style="display:none">UTC</span>
<input type="date" id="map-date" style="display:none" onchange="mapDateChanged()" aria-label="Imagery date">
<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</button>
</div>
<div class="map-stage" id="map-wrap">
<div id="map"></div>
<!-- Layer control panel -->
<div id="layer-panel">
<div class="lp-head" onclick="toggleLayerPanel()" role="button" tabindex="0" aria-expanded="true"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();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.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;">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.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;">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-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-blips-on" checked onchange="toggleBlips()"> <span class="lp-dot blips"></span> Event Blips</label>
<span class="lp-count" id="lp-blips-count">0</span>
</div>
<div class="lp-sub">
<label style="display:flex;align-items:center;gap:0.35rem;font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;">Since
<select class="lp-select" id="lp-blips-since" onchange="setBlipsSince()">
<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="">All time</option>
</select>
</label>
</div>
<div class="lp-note">Geolocated events from the ingest pipeline. Color = source type.</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-news-on" checked onchange="toggleNewsPins()"> <span class="lp-dot news"></span> Critical News</label>
<span class="lp-count" id="lp-news-count">0</span>
</div>
<div class="lp-note">LLM-estimated locations from the 15-min brief. Pins only for critical/high.</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-radar-on" checked onchange="toggleRadar()"> <span class="lp-dot radar"></span> Radar</label>
<span class="lp-count" id="lp-radar-count">IEM</span>
</div>
<div class="lp-opacity">Opacity <input type="range" id="lp-radar-opacity" min="5" max="100" value="70" oninput="setRadarOpacity(this.value)"><b id="lp-radar-val">70%</b></div>
<div class="lp-note">IEM NEXRAD over CONUS, RainViewer elsewhere. Tiles load in the browser.</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-alerts-on" checked onchange="toggleWxAlerts()"> <span class="lp-dot weather"></span> NWS / SBW Alerts</label>
<span class="lp-count" id="lp-alerts-count">0</span>
</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-thermal-on" onchange="toggleThermal()"> <span class="lp-dot thermal"></span> GIBS Thermal</label>
<span class="lp-count" id="lp-thermal-count">VIIRS</span>
</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-perim-on" checked onchange="togglePerimeters()"> <span class="lp-dot wfigs"></span> WFIGS Perimeters</label>
<span class="lp-count" id="lp-perim-count">0</span>
</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-incidents-on" onchange="toggleIncidents()"> <span class="lp-dot wfigs"></span> WFIGS Incidents</label>
<span class="lp-count" id="lp-incidents-count">0</span>
</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-ac-on" checked onchange="toggleAircraft()"> <span class="lp-dot flights"></span> Aircraft</label>
<span class="lp-count" id="lp-ac-count">0</span>
</div>
<div class="lp-note">Magenta = military · orange = firefighting · else altitude. Click a plane for type / squawk / operator.</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-trains-on" checked onchange="toggleTrains()"> <span class="lp-dot trains"></span> Trains</label>
<span class="lp-count" id="lp-trains-count">0</span>
</div>
<div class="lp-note">Amtraker · ODC-By</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-vessels-on" onchange="toggleVessels()"> <span class="lp-dot vessels"></span> Vessels · AIS</label>
<span class="lp-count" id="lp-vessels-count">0</span>
</div>
<div class="lp-note">Needs AISSTREAM_API_KEY. Magenta = military · gold = law/gov · teal = underway. Click for type / dest / IMO.</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<button class="btn" id="gf-draw" type="button" onclick="toggleGeofenceDraw()">Draw geofence</button>
</div>
<div class="lp-note">No Leaflet.Draw — click vertices, double-click to close. Saved to POST /api/geofences.</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-storms-on" checked onchange="toggleStorms()"> <span class="lp-dot storms"></span> NHC Storms</label>
<span class="lp-count" id="lp-storms-count">0</span>
</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 class="map-hint" id="map-hint">Initializing…</div>
<div class="hud-chips" id="hud-chips">
<div class="hud-chip" id="hud-fires"><span class="c fires"></span>FIRMS <b id="hud-fires-n"></b></div>
<div class="hud-chip" id="hud-cams"><span class="c cams"></span>CAMS <b id="hud-cams-n"></b></div>
<div class="hud-chip" id="hud-blips"><span class="c blips"></span>BLIPS <b id="hud-blips-n"></b></div>
<div class="hud-chip" id="hud-news"><span class="c news"></span>NEWS <b id="hud-news-n"></b></div>
</div>
<div id="dvr-bar" class="live">
<button class="btn" id="dvr-live" type="button" onclick="dvrGoLive()">LIVE</button>
<input type="range" id="dvr-slider" min="0" max="360" value="360" oninput="dvrScrub(this.value)" aria-label="DVR time">
<span id="dvr-label">live</span>
</div>
<div id="dvr-toast"></div>
</div>
</section>
<!-- ───────────── VIEW: NEWS BRIEF ───────────── -->
<section class="view" id="view-news" aria-label="News brief">
<div class="subview">
<h2><span class="tick"></span> Global News Brief</h2>
<p class="sub">Scraped continuously · latest LLM executive summary pinned above</p>
<div class="news-head">
<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>
<div class="news-summary" id="news-summary-wrap">
<div class="ns-kicker">Executive Summary <span class="ns-badge" id="ns-badge">WAITING</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 Nous summarizer is idle until <code>NOUS_API_KEY</code> is set on the Pi and a model is chosen in Settings.</div>
</div>
</div>
<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>
</section>
<!-- ───────────── VIEW: EVENTS ───────────── -->
<section class="view" id="view-events" aria-label="Events">
<div class="subview">
<h2><span class="tick"></span> Event Stream</h2>
<p class="sub">Ingested intel events across all sources</p>
<div class="stat-grid" id="summary-cards">
<div class="panel stat-card"><h3>Total Events</h3><div class="value" id="total-events">-</div></div>
<div class="panel stat-card"><h3>Events (24h)</h3><div class="value cyan" id="events-24h">-</div><div class="set-note" style="margin-top:0.2rem">last 24 hours</div></div>
<div class="panel stat-card"><h3>Active Sources</h3><div class="value" id="active-sources">-</div></div>
<div class="panel stat-card"><h3>Open Alerts</h3><div class="value red" id="open-alerts">-</div></div>
<div class="panel stat-card"><h3>Tracked Entities</h3><div class="value" id="tracked-entities">-</div></div>
<div class="panel stat-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="set-note" style="margin-top:0.35rem" id="sent-detail"></div>
</div>
</div>
<div class="panel" style="margin-bottom:1.1rem">
<h3>Search Events</h3>
<div class="search-box">
<input type="text" id="search-q" placeholder="Search events by keyword…" onkeydown="if(event.key==='Enter')searchEvents()" />
<button class="btn" onclick="searchEvents()">Search</button>
</div>
</div>
<div class="panel" id="search-results" style="display:none;margin-bottom:1.1rem">
<h3>Search Results (<span id="search-total">0</span>)</h3>
<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 class="panel">
<h3>Recent Events</h3>
<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>
</div>
</section>
<!-- ───────────── VIEW: ALERTS ───────────── -->
<section class="view" id="view-alerts" aria-label="Alerts">
<div class="subview">
<h2><span class="tick"></span> Open Alerts</h2>
<p class="sub">Rule-triggered alerts from entity mentions, sentiment shifts, geo proximity &amp; thresholds</p>
<div class="panel">
<table>
<thead><tr><th>Time</th><th>Severity</th><th>Type</th><th>Title</th></tr></thead>
<tbody id="alerts-body"></tbody>
</table>
</div>
</div>
</section>
<!-- ───────────── VIEW: ENTITIES ───────────── -->
<section class="view" id="view-entities" aria-label="Entities">
<div class="subview">
<h2><span class="tick"></span> Tracked Entities</h2>
<p class="sub">People, organizations, locations, topics &amp; assets of interest</p>
<div class="panel">
<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>
</div>
</section>
<!-- ───────────── VIEW: INGEST ───────────── -->
<section class="view" id="view-ingest" aria-label="Ingest">
<div class="subview">
<h2><span class="tick"></span> Data Ingestion</h2>
<p class="sub">Manually trigger feed pulls &amp; NATS processing</p>
<div class="stat-grid">
<div class="panel">
<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-0);border:1px solid var(--line);border-radius:4px;padding:0.45rem;color:var(--text);font-size:0.85rem">
<button class="btn" onclick="ingestRSS()">Fetch</button>
</div>
</div>
<div class="panel">
<h3>GDELT Articles</h3>
<p class="set-note" style="margin:0.5rem 0">Global news monitoring</p>
<button class="btn" onclick="ingestGDELT()">Fetch Latest</button>
</div>
<div class="panel">
<h3>Earthquakes (USGS)</h3>
<p class="set-note" style="margin:0.5rem 0">Last hour of seismic data</p>
<button class="btn" onclick="ingestEarthquakes()">Fetch Latest</button>
</div>
<div class="panel">
<h3>NATS Consumer</h3>
<p class="set-note" style="margin:0.5rem 0">Process pending messages</p>
<button class="btn" onclick="processNATS()">Process</button>
</div>
</div>
<div class="set-note" id="ingest-result" style="margin-top:0.6rem"></div>
</div>
</section>
<!-- ───────────── VIEW: API KEYS ───────────── -->
<section class="view" id="view-keys" aria-label="API keys">
<div class="subview">
<h2><span class="tick"></span> API Keys</h2>
<p class="sub">Keys used by ingest services (FIRMS, Nous Portal, Telegram, AIS, …). Stored in Postgres, never shown again — only status + last 4 chars.</p>
<div class="key-grid" id="keys-grid"></div>
</div>
</section>
<!-- ───────────── VIEW: SETTINGS ───────────── -->
<section class="view" id="view-settings" aria-label="Settings">
<div class="subview">
<h2><span class="tick"></span> Settings</h2>
<p class="sub">Terminal preferences — stored locally in this browser</p>
<div class="set-grid">
<div class="panel">
<h3>Market Data Stream</h3>
<p class="set-note" style="margin-bottom:0.9rem">Space reserved for the stock / futures ticker. The backend feed is not wired yet — when <code>GET /api/market</code> returns <code>{"symbols": [{"symbol","price","change","change_pct","updated_at"}]}</code>, the bottom ticker activates automatically.</p>
<div class="set-row">
<label for="set-symbols">Tracked symbols (comma separated)</label>
<textarea id="set-symbols" rows="3" spellcheck="false">^GSPC, ^IXIC, ^DJI, ES=F, NQ=F, YM=F, RTY=F, GC=F, CL=F, BTC-USD, ETH-USD, EURUSD=X</textarea>
</div>
<div class="set-actions">
<button class="btn" onclick="saveMarketSettings()">Save symbols</button>
<button class="btn" onclick="resetMarketSettings()">Reset</button>
</div>
<div class="set-saved" id="mkt-saved"></div>
</div>
<div class="panel">
<h3>News Summarizer</h3>
<p class="set-note" style="margin-bottom:0.9rem">Takes effect on the next 15-min run. No container restart.</p>
<div class="set-row">
<label>Provider</label>
<span>Nous Portal (inference-api.nousresearch.com)</span>
</div>
<div class="set-row">
<label>API key</label>
<span>set in API Keys as NOUS_API_KEY (never shown here)</span>
</div>
<div class="set-row">
<label for="set-summary-model">Model</label>
<select id="set-summary-model"></select>
</div>
<div class="set-row">
<label>Current</label>
<span id="set-summary-model-now"></span>
</div>
<div class="set-actions">
<button class="btn" onclick="saveSummaryModel()">Save model</button>
</div>
<div class="set-saved" id="sum-saved"></div>
</div>
<div class="panel">
<h3>Map Defaults</h3>
<p class="set-note" style="margin-bottom:0.9rem">Applied to the live map on load.</p>
<div class="set-row">
<label for="set-fires-since">Fires · since</label>
<select id="set-fires-since">
<option value="">All time</option>
<option value="6h">Last 6h</option>
<option value="24h">Last 24h</option>
<option value="48h">Last 48h</option>
<option value="7d">Last 7d</option>
<option value="30d">Last 30d</option>
</select>
</div>
<div class="set-row">
<label for="set-blips-since">Event blips · since</label>
<select id="set-blips-since">
<option value="6h">Last 6h</option>
<option value="24h">Last 24h</option>
<option value="48h">Last 48h</option>
<option value="7d">Last 7d</option>
<option value="">All time</option>
</select>
</div>
<div class="set-row">
<label>Layers on by default</label>
<div style="display:flex;gap:1.2rem;font-size:0.82rem">
<label style="display:flex;align-items:center;gap:0.4rem"><input type="checkbox" id="set-cams-on" checked style="accent-color:var(--cyan)"> Cameras</label>
<label style="display:flex;align-items:center;gap:0.4rem"><input type="checkbox" id="set-blips-on" checked style="accent-color:var(--cyan)"> Blips</label>
<label style="display:flex;align-items:center;gap:0.4rem"><input type="checkbox" id="set-fires-on" checked style="accent-color:var(--cyan)"> Fires</label>
</div>
</div>
<div class="set-actions">
<button class="btn" onclick="saveMapSettings()">Save defaults</button>
<button class="btn" onclick="resetMapSettings()">Reset</button>
</div>
<div class="set-saved" id="map-saved"></div>
</div>
<div class="panel">
<h3>System</h3>
<div id="sys-info">
<div class="sys-row"><span class="k">API status</span><span class="v" id="sys-health"></span></div>
<div class="sys-row"><span class="k">Total events</span><span class="v" id="sys-events"></span></div>
<div class="sys-row"><span class="k">Active sources</span><span class="v" id="sys-sources"></span></div>
<div class="sys-row"><span class="k">Open alerts</span><span class="v" id="sys-alerts"></span></div>
<div class="sys-row"><span class="k">Tracked entities</span><span class="v" id="sys-entities"></span></div>
<div class="sys-row"><span class="k">News cycle</span><span class="v">analyst 15m · scraper continuous</span></div>
<div class="sys-row"><span class="k">Market feed</span><span class="v" id="sys-market">STANDBY</span></div>
</div>
<div class="set-actions">
<button class="btn" onclick="loadSummary();checkHealth();renderSysInfo()">Refresh</button>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- ═════════════ BOTTOM DOCK: TICKERS ═════════════ -->
<footer class="dock chrome">
<div class="ticker market" aria-label="Market data ticker">
<div class="tick-label"><span class="st"></span> MKT</div>
<div class="tick-wrap">
<div class="tick-track" id="mkt-track"></div>
</div>
<div class="tick-tag standby" id="mkt-tag">STANDBY</div>
</div>
<div class="ticker news" aria-label="Breaking news ticker">
<div class="tick-label"><span class="st"></span> NEWS</div>
<div class="tick-wrap">
<div class="tick-track" id="nt-track"></div>
</div>
<div class="tick-tag" id="nt-tag">15 MIN</div>
</div>
</footer>
<script src="/static/vendor/leaflet/leaflet.js"></script>
<script src="/static/vendor/leaflet/leaflet.markercluster.js"></script>
<script src="/static/vendor/leaflet/leaflet.heat.js"></script>
<script>
const API = '';
function loadHls() {
if (window.Hls) return Promise.resolve();
if (window._hlsLoading) return window._hlsLoading;
window._hlsLoading = new Promise((res, rej) => {
const s = document.createElement('script');
s.src = '/static/vendor/hls/hls.min.js';
s.onload = res;
s.onerror = rej;
document.head.appendChild(s);
});
return window._hlsLoading;
}
/* ═══════════════ BOOT SPLASH ═══════════════ */
(function () {
const boot = document.getElementById('boot');
const skip = () => boot.classList.add('gone');
const seen = sessionStorage.getItem('osint.booted');
if (seen || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { skip(); }
else {
boot.addEventListener('click', skip);
setTimeout(skip, 1100);
sessionStorage.setItem('osint.booted', '1');
}
})();
/* ═══════════════ TOP BAR: clock + health ═══════════════ */
function tickClock() {
const el = document.getElementById('utc-clock');
if (el) el.textContent = new Date().toISOString().slice(11, 19);
}
tickClock();
setInterval(tickClock, 1000);
async function checkHealth() {
const dot = document.getElementById('health-dot');
const txt = document.getElementById('health-text');
try {
const r = await fetch(`${API}/api/health`);
const d = await r.json();
if (r.ok && d && d.status === 'ok') {
dot.className = 'dot ok';
txt.textContent = 'SYSTEMS OK';
} else if (r.ok) {
dot.className = 'dot bad';
txt.textContent = 'DEGRADED';
} else {
dot.className = 'dot bad';
txt.textContent = 'DEGRADED';
}
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
} catch (e) {
dot.className = 'dot bad';
txt.textContent = 'UNREACHABLE';
document.getElementById('last-update').textContent = '—';
}
}
/* ═══════════════ NAV (dropdown) ═══════════════ */
const VIEWS = ['map', 'news', 'events', 'alerts', 'entities', 'ingest', 'keys', 'settings'];
function toggleNav(force) {
const btn = document.getElementById('nav-btn');
const menu = document.getElementById('nav-menu');
const open = force !== undefined ? force : !menu.classList.contains('open');
menu.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) menu.querySelector('.nav-item').focus();
}
function closeNav() { toggleNav(false); }
function initNav() {
const btn = document.getElementById('nav-btn');
const menu = document.getElementById('nav-menu');
btn.addEventListener('click', (e) => { e.stopPropagation(); toggleNav(); });
document.addEventListener('click', (e) => {
// Only close when the menu is open AND the click landed outside both
// the button and the menu. (A stray synthetic/double click on <body>
// must not instantly undo the open.)
if (!menu.classList.contains('open')) return;
if (!menu.contains(e.target) && !btn.contains(e.target)) closeNav();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeNav();
if (e.key === 'ArrowDown' && menu.classList.contains('open')) {
e.preventDefault();
const items = [...menu.querySelectorAll('.nav-item')];
const idx = items.indexOf(document.activeElement);
items[(idx + 1) % items.length].focus();
}
if (e.key === 'ArrowUp' && menu.classList.contains('open')) {
e.preventDefault();
const items = [...menu.querySelectorAll('.nav-item')];
const idx = items.indexOf(document.activeElement);
items[(idx - 1 + items.length) % items.length].focus();
}
});
menu.querySelectorAll('.nav-item').forEach(it => {
it.addEventListener('click', () => { showView(it.dataset.view); closeNav(); });
});
}
function showView(name) {
if (!VIEWS.includes(name)) name = 'map';
VIEWS.forEach(v => {
document.getElementById('view-' + v).classList.toggle('active', v === name);
});
document.getElementById('stage').classList.toggle('scroll', name !== 'map');
if (name === 'alerts') loadAlerts();
if (name === 'entities') loadEntities();
if (name === 'keys') loadKeys();
if (name === 'map') {
initMap();
// Leaflet was display:none — re-measure, then refresh live overlays.
if (map) {
setTimeout(() => { map.invalidateSize(); }, 60);
if (firesOn) loadFires();
if (camsOn) loadCams();
if (blipsOn) loadBlips();
if (newsOn) loadNewsPins();
}
}
if (name === 'news') loadNews(false);
if (name === 'events') { loadSummary(); loadEvents(); }
if (name === 'settings') { loadSummary(); renderSysInfo(); loadSettings(); }
window.scrollTo(0, 0);
}
/* ═══════════════ MARKET TICKER (space reserved — backend stream later) ═══
Contract for the future backend feed:
GET /api/market → {"symbols": [
{"symbol": "^GSPC", "price": 6123.4, "change": 18.2, "change_pct": 0.30, "updated_at": "2026-08-27T16:00:00Z"}
]}
Until the endpoint answers 200, the ticker renders the configured symbols
in STANDBY (dimmed "--" quotes). The strip auto-promotes to LIVE the
moment the backend stream appears. */
const MARKET_POLL_MS = 15000;
let marketState = 'standby'; // standby | live | err
let marketSymbols = [];
const DEFAULT_SYMBOLS = ['^GSPC', '^IXIC', '^DJI', 'ES=F', 'NQ=F', 'YM=F', 'RTY=F', 'GC=F', 'CL=F', 'BTC-USD', 'ETH-USD', 'EURUSD=X'];
function loadMarketSymbols() {
try {
const raw = localStorage.getItem('osint.marketSymbols');
marketSymbols = raw ? raw.split(',').map(s => s.trim()).filter(Boolean) : DEFAULT_SYMBOLS.slice();
} catch (e) { marketSymbols = DEFAULT_SYMBOLS.slice(); }
}
function marketItem(sym, price, chg, pct, standby) {
const cls = standby ? 'standby' : '';
const pr = price != null ? price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '--.--';
if (standby) return `<span class="tick-item standby"><span class="sym">${esc(sym)}</span><span class="price">${pr}</span><span class="sep">▸</span></span>`;
const dir = chg > 0.0001 ? 'up' : chg < -0.0001 ? 'down' : 'flat';
const sign = chg > 0 ? '+' : '';
return `<span class="tick-item"><span class="sym">${esc(sym)}</span><span class="price">${pr}</span>` +
`<span class="chg ${dir}">${sign}${chg.toFixed(2)} (${sign}${pct.toFixed(2)}%)</span><span class="sep">▸</span></span>`;
}
function buildMarketTrack(standby) {
const track = document.getElementById('mkt-track');
const group = marketSymbols.length
? marketSymbols.map(s => marketItem(s, null, 0, 0, true)).join('')
: DEFAULT_SYMBOLS.map(s => marketItem(s, null, 0, 0, true)).join('');
// Twin groups for a seamless loop.
track.innerHTML = `<span class="tick-group">${group}</span><span class="tick-group">${group}</span>`;
}
function setMarketTag(state) {
const tag = document.getElementById('mkt-tag');
const sys = document.getElementById('sys-market');
tag.className = 'tick-tag ' + state;
if (state === 'live') { tag.textContent = 'LIVE'; if (sys) sys.textContent = 'LIVE'; }
else if (state === 'err') { tag.textContent = 'FEED ERR'; if (sys) sys.textContent = 'ERR'; }
else { tag.textContent = 'STANDBY'; if (sys) sys.textContent = 'STANDBY'; }
}
async function probeMarket() {
try {
const r = await fetch(`${API}/api/market`, { cache: 'no-store' });
if (!r.ok) throw new Error('http ' + r.status);
const d = await r.json();
const syms = Array.isArray(d && d.symbols) ? d.symbols : [];
if (!syms.length) throw new Error('empty feed');
marketState = 'live';
setMarketTag('live');
const track = document.getElementById('mkt-track');
const group = syms.map(s => marketItem(
s.symbol, s.price, s.change, s.change_pct, false
)).join('');
track.innerHTML = `<span class="tick-group">${group}</span><span class="tick-group">${group}</span>`;
} catch (e) {
if (marketState === 'live') {
marketState = 'err';
setMarketTag('err');
} else {
marketState = 'standby';
setMarketTag('standby');
buildMarketTrack(true);
}
}
}
function initMarketTicker() {
loadMarketSymbols();
buildMarketTrack(true);
setMarketTag('standby');
// GET /api/market is not wired — do not 404-poll every 15s.
}
/* ═══════════════ NEWS FEED + TICKER ═══════════════ */
let newsInterval = null;
const NEWS_REFRESH_MS = 900000; // 15-min cycle
let newsTickerBuilt = false;
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));
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
h = h.replace(/^### (.+)$/gm, '<h4>$1</h4>');
h = h.replace(/^## (.+)$/gm, '<h3>$1</h3>');
h = h.replace(/^# (.+)$/gm, '<h2>$1</h2>');
h = h.replace(/^[\*\-]\s(.+)$/gm, '<li>$1</li>');
h = h.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
h = h.replace(/\n{2,}/g, '</p><p>');
h = h.replace(/\n/g, '<br>');
return '<p>'+h+'</p>';
}
async function loadNews(force) {
const upd = document.getElementById('news-updated');
if (upd) upd.textContent = 'updating…';
let articles = [];
let summaries = [];
let recaps = [];
let tickerItems = [];
try {
const [rArticles, rSumm, rRecap, rTick] = await Promise.all([
fetch(`${API}/api/news?limit=100`),
fetch(`${API}/api/news/summaries?limit=1`),
fetch(`${API}/api/news/summaries?kind=daily_recap&limit=1`),
fetch(`${API}/api/news/ticker?limit=20`),
]);
if (rArticles.ok) articles = await rArticles.json();
if (rSumm.ok) summaries = await rSumm.json();
if (rRecap.ok) recaps = await rRecap.json();
if (rTick.ok) tickerItems = await rTick.json();
} catch (e) {
const list = document.getElementById('news-list');
const body = document.getElementById('ns-body');
if (list) list.innerHTML = '<div class="news-error">Failed to load news feed: ' + newsEsc(e.message || e) + '</div>';
if (body) body.innerHTML = '<div class="ns-error">Failed to load summary: ' + newsEsc(e.message || e) + '</div>';
if (upd) upd.textContent = 'error';
console.error('News load failed', e);
return;
}
renderNewsSummary(recaps.length ? recaps : summaries, recaps.length > 0);
renderNewsList(articles);
renderNewsTicker(articles, summaries, tickerItems);
if (upd) upd.textContent = 'updated ' + new Date().toLocaleTimeString();
if (!newsInterval) {
newsInterval = setInterval(() => loadNews(false), NEWS_REFRESH_MS);
}
}
function renderNewsSummary(summaries, isRecap) {
const bodyEl = document.getElementById('ns-body');
const timeEl = document.getElementById('ns-time');
const badgeEl = document.getElementById('ns-badge');
if (!bodyEl) return;
if (!summaries || !summaries.length) {
bodyEl.innerHTML = '<div class="ns-empty">No executive summary yet — the Nous summarizer is idle until <code>NOUS_API_KEY</code> is set on the Pi and a model is chosen in Settings.</div>';
timeEl.textContent = '—';
badgeEl.textContent = 'WAITING';
return;
}
const s = summaries[0];
badgeEl.textContent = isRecap || s.kind === 'daily_recap' ? 'DAILY RECAP' : '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');
if (!listEl) return;
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 continuously. 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 renderNewsTicker(articles, summaries, tickerItems) {
const track = document.getElementById('nt-track');
if (!track) return;
let items = '';
const ticks = Array.isArray(tickerItems) ? tickerItems : [];
if (ticks.length) {
ticks.forEach(t => {
const imp = String(t.importance || '').toLowerCase();
const tag = imp === 'critical' ? 'CRITICAL' : 'HIGH';
const loc = (t.location_name || '').trim();
const inner =
`<span class="b-tag ${imp === 'critical' ? 'critical' : 'high'}">${tag}</span>` +
`<span>${newsEsc((t.headline || '').trim())}</span>` +
(loc ? `<span class="dom">${newsEsc(loc)}</span>` : '') +
`<span class="tt">${newsTimeAgo(t.created_at)}</span><span class="sep">▸</span>`;
const url = (t.url || '').trim();
if (url) {
items += `<a class="tick-item" href="${newsEsc(url)}" target="_blank" rel="noopener">${inner}</a>`;
} else {
items += `<button class="tick-item" onclick="showView('news')" title="Open news brief">${inner}</button>`;
}
});
} else {
// Quiet hour: brief chip + article titles so the dock is never blank.
const summ = summaries && summaries.length ? summaries[0] : null;
const briefSrc = summ && (summ.summary_en || summ.summary_text);
if (briefSrc) {
const brief = String(briefSrc).replace(/\s+/g, ' ').trim();
items += `<button class="tick-item brief" onclick="showView('news')" title="Open full brief">` +
`<span class="b-tag">LLM BRIEF</span><span>${newsEsc(brief.slice(0, 120))}${brief.length > 120 ? '…' : ''}</span>` +
`<span class="tt">${newsTimeAgo(summ.batch_timestamp)}</span><span class="sep">▸</span></button>`;
}
(articles || []).slice(0, 10).forEach(a => {
items += `<a class="tick-item" href="${newsEsc(a.url || '#')}" target="_blank" rel="noopener">` +
`<span class="dom">${newsEsc(a.domain || '?')}</span>` +
`<span>${newsEsc((a.title || '').trim())}</span>` +
`<span class="tt">${newsTimeAgo(a.timestamp)}</span><span class="sep">▸</span></a>`;
});
}
if (!items) {
items = `<span class="tick-item standby">No headlines yet — scraper idle</span>`;
}
track.innerHTML = `<span class="tick-group">${items}</span><span class="tick-group">${items}</span>`;
newsTickerBuilt = true;
}
/* ═══════════════ SUMMARY / EVENTS / ALERTS / ENTITIES / SEARCH ═══════════ */
let lastSummary = null; // last /api/analytics/summary payload for the Settings System panel
async function loadSummary() {
try {
const r = await fetch(`${API}/api/analytics/summary`);
const d = await r.json();
lastSummary = d;
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)})`;
renderSysInfo(d);
} catch(e) { console.error('Summary load failed', e); }
}
function renderSysInfo(d) {
// No argument → re-render from the last summary snapshot (a bare refresh
// must not clobber real values with placeholder dashes).
if (!d) d = lastSummary || {};
const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
set('sys-events', d.total_events ?? '—');
set('sys-sources', d.active_sources ?? '—');
set('sys-alerts', d.open_alerts ?? '—');
set('sys-entities', d.tracked_entities ?? '—');
set('sys-health', (document.getElementById('health-dot') || {}).className === 'dot ok' ? 'OK' : '—');
}
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 ═══════════════ */
const KEY_PATTERNS = {
'FIRMS_MAP_KEY': /^[0-9a-fA-F]{32}$/,
'NOUS_API_KEY': /^.{16,}$/,
'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); }
}
/* ═══════════════ INGEST TRIGGERS ═══════════════ */
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();
if (blipsOn && map) loadBlips();
}
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();
}
/* ═══════════════ SETTINGS ═══════════════ */
function saveMarketSettings() {
const raw = document.getElementById('set-symbols').value;
const syms = raw.split(',').map(s => s.trim()).filter(Boolean);
try {
localStorage.setItem('osint.marketSymbols', syms.join(','));
marketSymbols = syms;
buildMarketTrack(true);
if (marketState !== 'live') setMarketTag('standby');
document.getElementById('mkt-saved').textContent = 'Symbols saved — ticker updated.';
} catch (e) {
document.getElementById('mkt-saved').textContent = 'Could not save (storage unavailable).';
}
}
function resetMarketSettings() {
try { localStorage.removeItem('osint.marketSymbols'); } catch (e) {}
marketSymbols = DEFAULT_SYMBOLS.slice();
document.getElementById('set-symbols').value = DEFAULT_SYMBOLS.join(', ');
buildMarketTrack(true);
document.getElementById('mkt-saved').textContent = 'Defaults restored.';
}
function saveMapSettings() {
const prefs = {
firesSince: document.getElementById('set-fires-since').value,
blipsSince: document.getElementById('set-blips-since').value,
camsOn: document.getElementById('set-cams-on').checked,
blipsOn: document.getElementById('set-blips-on').checked,
firesOn: document.getElementById('set-fires-on').checked,
};
try { localStorage.setItem('osint.mapDefaults', JSON.stringify(prefs)); } catch (e) {}
applyMapSettings(prefs, true);
document.getElementById('map-saved').textContent = 'Map defaults saved & applied.';
}
function resetMapSettings() {
try { localStorage.removeItem('osint.mapDefaults'); } catch (e) {}
document.getElementById('set-fires-since').value = '24h';
document.getElementById('set-blips-since').value = '24h';
document.getElementById('set-cams-on').checked = true;
document.getElementById('set-blips-on').checked = true;
document.getElementById('set-fires-on').checked = true;
applyMapSettings({firesSince:'24h', blipsSince:'24h', camsOn:true, blipsOn:true, firesOn:true}, true);
document.getElementById('map-saved').textContent = 'Defaults restored.';
}
function applyMapSettings(prefs, live) {
const fs = document.getElementById('lp-fires-since');
const bs = document.getElementById('lp-blips-since');
if (fs) fs.value = prefs.firesSince || '24h';
if (bs) bs.value = prefs.blipsSince || '24h';
const cf = document.getElementById('lp-cams-on');
const bf = document.getElementById('lp-blips-on');
const ff = document.getElementById('lp-fires-on');
if (cf) cf.checked = prefs.camsOn !== false;
if (bf) bf.checked = prefs.blipsOn !== false;
if (ff) ff.checked = prefs.firesOn !== false;
if (!live) return;
if (map) {
if (prefs.camsOn === false && camsGroup) { map.removeLayer(camsGroup); camsGroup = null; }
if (prefs.blipsOn === false && blipsGroup) { map.removeLayer(blipsGroup); blipsGroup = null; }
if (prefs.firesOn === false && firesHeat) { map.removeLayer(firesHeat); firesHeat = null; }
if (prefs.firesOn !== false && firesOn) loadFires();
if (prefs.camsOn !== false && camsOn) loadCams();
if (prefs.blipsOn !== false && blipsOn) loadBlips();
}
firesSince = prefs.firesSince || firesSince;
blipsSince = prefs.blipsSince || blipsSince;
camsOn = prefs.camsOn !== false;
blipsOn = prefs.blipsOn !== false;
firesOn = prefs.firesOn !== false;
syncHud();
}
function initSettings() {
try {
const raw = localStorage.getItem('osint.marketSymbols');
document.getElementById('set-symbols').value = raw ? raw.split(',').map(s=>s.trim()).filter(Boolean).join(', ') : DEFAULT_SYMBOLS.join(', ');
const prefs = JSON.parse(localStorage.getItem('osint.mapDefaults') || 'null');
if (prefs) {
document.getElementById('set-fires-since').value = prefs.firesSince || '24h';
document.getElementById('set-blips-since').value = prefs.blipsSince || '24h';
document.getElementById('set-cams-on').checked = prefs.camsOn !== false;
document.getElementById('set-blips-on').checked = prefs.blipsOn !== false;
document.getElementById('set-fires-on').checked = prefs.firesOn !== false;
}
} catch (e) {}
}
async function loadSettings() {
const sel = document.getElementById('set-summary-model');
const now = document.getElementById('set-summary-model-now');
try {
const [setR, modR] = await Promise.all([
fetch(`${API}/api/settings`),
fetch(`${API}/api/news/models`),
]);
const settings = setR.ok ? await setR.json() : {};
const catalog = modR.ok ? await modR.json() : {models: []};
const saved = (settings.summary_model || '').trim();
const ids = (catalog.models || []).map(m => m && m.id).filter(Boolean);
if (saved && ids.indexOf(saved) === -1) ids.unshift(saved);
if (sel) {
sel.innerHTML = ids.map(id => `<option value="${esc(id)}">${esc(id)}</option>`).join('');
if (saved) sel.value = saved;
}
if (now) now.textContent = saved || '—';
} catch (e) {
console.error('Settings load failed', e);
}
}
async function saveSummaryModel() {
const sel = document.getElementById('set-summary-model');
const flash = document.getElementById('sum-saved');
const now = document.getElementById('set-summary-model-now');
const summary_model = ((sel && sel.value) || '').trim();
if (!summary_model) {
if (flash) flash.textContent = 'Choose a model to save.';
return;
}
try {
const r = await fetch(`${API}/api/settings`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({summary_model}),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
if (flash) flash.textContent = (typeof d.detail === 'string' ? d.detail : 'Save failed');
return;
}
if (now) now.textContent = d.summary_model || summary_model;
if (sel && d.summary_model) sel.value = d.summary_model;
if (flash) flash.textContent = 'Model saved.';
} catch (e) {
if (flash) flash.textContent = 'Network error.';
}
}
/* ═══════════════ MAP (NASA GIBS basemap + FIRMS + cameras + blips) ═══════
The satellite map core is production-proven — preserved verbatim. */
let map = null, mapLayer = null, mapInitStarted = false;
let firesHeat = null, camsGroup = null, firesOn = false, camsOn = false;
let blipsGroup = null, blipsOn = false, blipsSince = '24h';
let newsGroup = null, newsOn = false, newsReq = 0;
let firesOpacity = 0.65, firesColor = 'brightness', firesSince = '24h';
let camsOpacity = 1.0, baseOpacity = 1.0;
let camReq = 0, fireReq = 0, blipReq = 0; // stale-response guards (rapid zoom races)
let mapLayers = [];
let mapDomain = null;
let mapLatest = null;
let mapAttributionAdded = null;
let extraAttribs = new Set();
let radarLayer = null, radarOn = true, radarOpacity = 0.7, radarMeta = null, radarTimer = null;
let thermalLayer = null, thermalOn = false;
let wxAlertsGroup = null, wxAlertsOn = true;
let perimGroup = null, perimOn = true;
let incidentsGroup = null, incidentsOn = false;
let acGroup = null, acOn = true;
let trainsGroup = null, trainsOn = true;
let vesselsGroup = null, vesselsOn = false;
let stormsGroup = null, stormsOn = true;
let overlayReq = {ac:0, trains:0, vessels:0, alerts:0, perim:0, incidents:0, storms:0};
let moveDebounce = null;
let overlayAbort = null;
let lastCell = '';
let lastVesselSubBox = ''; // last viewport box sent to the AIS stream
let vesselRefollowTimer = null; // one follow-up fetch after a retune
function bboxCell() {
if (!map) return '';
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
}
let liveWs = null;
let wsRetryMs = 1000;
const FF_ICAO = new Set(['AT802','AT8T','AT8P','C130','C30J','C130J','DC10','MD10','MD87','S64','UH60','S70','CL415','CL215','B350','OV10','C208']);
let firefighterHex = new Set();
let dvrTs = null;
let dvrMinMs = 0, dvrMaxMs = 0;
let gfDrawOn = false, gfVerts = [], gfLayer = null, gfSaved = null;
function liveWsUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + '/ws/live';
}
function sendLiveViewport() {
if (!liveWs || liveWs.readyState !== 1 || !map) return;
liveWs.send(JSON.stringify({ type: 'viewport', bbox: currentBBox() }));
}
function upsertLivePoint(group, p, colorFn, feed) {
if (!map || !p || p.id == null || p.lat == null || p.lon == null) return group;
if (!group || !map.hasLayer(group) || !group._osintById) {
return renderPoints(group, [p], colorFn, true, feed);
}
const id = String(p.id);
const col = sanitizeColor(colorFn(p), '#35e0ff');
let m = group._osintById.get(id);
if (m) {
m.setLatLng([p.lat, p.lon]);
if (feed) m.setIcon(feedIcon(feed, col, p.heading));
else if (m.setStyle) m.setStyle({ color: col, fillColor: col });
} else {
m = makePointMarker(p, colorFn, feed, pointCanvas());
if (m) { group.addLayer(m); group._osintById.set(id, m); }
}
return group;
}
function applyLiveMarker(kind, p) {
if (!p) return;
if (kind === 'ais' && vesselsOn && map && map.getZoom() > 3 && !dvrTs) {
vesselsGroup = upsertLivePoint(vesselsGroup, p, q => {
const sog = Number(q.speed || 0);
return sog > 0.5 ? '#2dd4bf' : '#64748b';
}, 'vessel');
} else if (kind === 'adsb' && acOn && map && map.getZoom() > 3 && !dvrTs) {
acGroup = upsertLivePoint(acGroup, p, q => acColor(q), 'ac');
} else if (kind === 'geofence_alert') {
showGeofenceToast(p);
} else if (kind === 'fire_aircraft') {
firefighterHex.add(String(p.aircraft_hex || ''));
if (acOn && p.aircraft_lat != null) {
acGroup = upsertLivePoint(acGroup, {
id: p.aircraft_hex, lat: p.aircraft_lat, lon: p.aircraft_lon,
label: p.label || p.aircraft_hex, extra: { type: p.aircraft_type, firefighter: true, hex: p.aircraft_hex, src: 'adsb.lol' },
}, q => acColor(q), 'ac');
}
}
}
function acColor(p) {
const extra = p.extra || {};
const t = String(extra.type || '').toUpperCase();
const em = String(extra.emergency || '').toLowerCase();
if (em && em !== 'none') return '#ff5d5d';
if (extra.firefighter || firefighterHex.has(String(p.id)) || FF_ICAO.has(t)) return '#fb923c';
if (extra.role === 'military') return '#f472b6';
return altColor(extra.alt_baro);
}
function connectLiveWs() {
if (liveWs && (liveWs.readyState === 0 || liveWs.readyState === 1)) return;
try { liveWs = new WebSocket(liveWsUrl()); } catch (e) {
const delay = wsRetryMs;
wsRetryMs = Math.min(wsRetryMs * 2, 60000);
setTimeout(connectLiveWs, delay);
return;
}
liveWs.onopen = () => { wsRetryMs = 1000; sendLiveViewport(); };
liveWs.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
applyLiveMarker(msg.type, msg.payload);
} catch (e) { /* ignore malformed */ }
};
liveWs.onclose = () => {
liveWs = null;
const delay = wsRetryMs;
wsRetryMs = Math.min(wsRetryMs * 2, 60000);
setTimeout(connectLiveWs, delay);
};
}
function overlayFetch(url) {
return fetch(url, overlayAbort ? { signal: overlayAbort.signal } : {});
}
function isAbort(e) { return e && e.name === 'AbortError'; }
const pointCanvas = () => L.canvas({ padding: 0.5 });
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('');
const preferred =
mapLayers.find(l => l.id === 'BlueMarble_ShadedRelief_Bathymetry')
|| 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;
const startHlsFrom = (root) => {
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);
};
const startHls = () => {
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();
}
};
if (window.Hls) startHls();
else loadHls().then(startHls).catch(fallback);
};
map.on('popupopen', (e) => {
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
const root = e.popup.getElement();
const box = root && root.querySelector('.cam-pop[data-cam-id]');
if (box && box.dataset.loaded !== '1') {
fetch(`${API}/api/cameras/${box.dataset.camId}`)
.then(r => { if (!r.ok) throw new Error('camera detail'); return r.json(); })
.then(c => {
box.outerHTML = camPopupHtml(c);
startHlsFrom(e.popup.getElement());
})
.catch(() => {
const ph = box.querySelector('.thumb.placeholder');
if (ph) ph.textContent = 'preview unavailable';
});
return;
}
startHlsFrom(root);
});
map.on('popupopen', (e) => { loadPlanePhoto(e.popup); });
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 (moveDebounce) clearTimeout(moveDebounce);
moveDebounce = setTimeout(() => {
const cell = bboxCell();
if (cell === lastCell) return;
lastCell = cell;
if (overlayAbort) overlayAbort.abort();
overlayAbort = new AbortController();
if (firesOn) loadFires();
if (camsOn) loadCams();
if (blipsOn) loadBlips();
if (newsOn) loadNewsPins();
refreshLiveOverlays();
sendLiveViewport();
}, 500);
});
// Static Blue Marble has no time-domain fetch — don't block overlays on GIBS.
mapLayerChanged();
// Overlays default ON (checkboxes in the layer panel + saved settings)
applyMapSettings(readMapPrefs(), false);
firesOn = document.getElementById('lp-fires-on').checked;
camsOn = document.getElementById('lp-cams-on').checked;
blipsOn = document.getElementById('lp-blips-on').checked;
newsOn = document.getElementById('lp-news-on').checked;
firesSince = document.getElementById('lp-fires-since').value;
blipsSince = document.getElementById('lp-blips-since').value;
radarOn = document.getElementById('lp-radar-on').checked;
wxAlertsOn = document.getElementById('lp-alerts-on').checked;
thermalOn = document.getElementById('lp-thermal-on').checked;
perimOn = document.getElementById('lp-perim-on').checked;
incidentsOn = document.getElementById('lp-incidents-on').checked;
acOn = document.getElementById('lp-ac-on').checked;
trainsOn = document.getElementById('lp-trains-on').checked;
vesselsOn = document.getElementById('lp-vessels-on').checked;
stormsOn = document.getElementById('lp-storms-on').checked;
connectLiveWs();
requestAnimationFrame(() => {
if (firesOn) loadFires();
setTimeout(() => {
if (camsOn) loadCams();
if (blipsOn) loadBlips();
if (newsOn) loadNewsPins();
refreshLiveOverlays();
}, 250);
});
} catch(e) {
hint.textContent = `Failed to load map layers: ${e.message || e}`;
console.error('Map init failed', e);
}
}
function readMapPrefs() {
try {
return JSON.parse(localStorage.getItem('osint.mapDefaults') || 'null') || {};
} catch (e) { return {}; }
}
function syncHud() {
const set = (id, n, on) => {
const chip = document.getElementById('hud-' + id);
const num = document.getElementById('hud-' + id + '-n');
if (num) num.textContent = n == null ? '—' : Number(n).toLocaleString();
if (chip) chip.classList.toggle('off', on === false);
};
set('fires', firesOn ? (hudFiresCount) : '—', firesOn);
set('cams', camsOn ? (hudCamsCount) : '—', camsOn);
set('blips', blipsOn ? (hudBlipsCount) : '—', blipsOn);
set('news', newsOn ? (hudNewsCount) : '—', newsOn);
}
let hudFiresCount = null, hudCamsCount = null, hudBlipsCount = null, hudNewsCount = null;
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;
const hasTime = !!meta.has_time;
document.getElementById('map-time-label').style.display = hasTime ? '' : 'none';
document.getElementById('map-date').style.display = hasTime ? '' : '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) {
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)';
}
}
if (thermalOn) loadThermal();
}
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 quantizeBBox(bbox, decimals = 1) {
// Round outward (west/south floor, east/north ceil) so the box always
// covers the viewport; a coarse box keeps AIS stream retunes sparse.
const [w, s, e, n] = bbox.split(',').map(Number);
const p = Math.pow(10, decimals);
const wq = Math.floor(w * p) / p;
const sq = Math.floor(s * p) / p;
const eq = Math.ceil(e * p) / p;
const nq = Math.ceil(n * p) / p;
return [wq, sq, eq, nq].map(v => v.toFixed(decimals)).join(',');
}
function toggleLayerPanel() {
const panel = document.getElementById('layer-panel');
panel.classList.toggle('collapsed');
panel.querySelector('.lp-head').setAttribute('aria-expanded', !panel.classList.contains('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) {
const confidence = f.confidence || f.c;
const brightness = f.brightness != null ? f.brightness : f.i;
if (firesColor === 'confidence') {
return confidence === 'h' ? 1.0 : confidence === 'l' ? 0.35 : 0.6;
}
const b = brightness || 300;
return Math.max(0.05, Math.min(1, (b - 290) / 110));
}
function firesGradient() {
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; }
hudFiresCount = null;
syncHud();
}
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();
}
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&format=heat`;
const since = sinceToISO(firesSince);
if (since) url += `&since=${encodeURIComponent(since)}`;
const r = await overlayFetch(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.lat ?? f.latitude, f.lon ?? 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();
hudFiresCount = fires.length;
syncHud();
document.getElementById('map-hint').textContent =
`${fires.length.toLocaleString()} fire hotspots in view` +
(since ? ` · since ${since.slice(0,16).replace('T',' ')}Z` : '');
} catch(e) {
if (isAbort(e)) return;
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; }
hudCamsCount = null;
syncHud();
}
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}">`;
}
function camPopupHtml(c) {
return `<div class="cam-pop" data-cam-id="${esc(c.id)}" data-loaded="1">` +
`<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>`;
}
async function loadCams() {
if (!map) return;
if (tooZoomedOut()) {
camsGroup = dropLayer(camsGroup);
markZoom('lp-cams-count');
hudCamsCount = null;
syncHud();
return;
}
const req = ++camReq;
try {
const r = await overlayFetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`);
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" data-cam-id="${esc(c.id)}">` +
`<b>${esc(c.location_name || 'Open camera')}</b>` +
`<div class="thumb placeholder">loading feed…</div></div>`));
});
camsGroup.addTo(map);
camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity));
document.getElementById('lp-cams-count').textContent = cams.length.toLocaleString();
hudCamsCount = cams.length;
syncHud();
document.getElementById('map-hint').textContent =
`${cams.length.toLocaleString()} open cameras in view`;
} catch(e) {
if (isAbort(e)) return;
document.getElementById('map-hint').textContent = `Cameras load failed: ${e.message || e}`;
console.error('Cameras load failed', e);
}
}
/* ── EVENT BLIPS (geolocated ingested events) ── */
const BLIP_COLORS = {
earthquake: '#ffb454',
fire: '#fb923c',
weather: '#35e0ff',
disaster: '#ff5d5d',
'gdel-t2': '#c084fc',
rss: '#ff2e97',
social: '#f0abfc',
satellite: '#a3e635',
};
function blipColor(src) { return BLIP_COLORS[src] || '#8b9db5'; }
async function toggleBlips() {
blipsOn = document.getElementById('lp-blips-on').checked;
if (blipsOn) await loadBlips();
else if (blipsGroup) { map.removeLayer(blipsGroup); blipsGroup = null; }
hudBlipsCount = null;
syncHud();
}
function setBlipsSince() {
blipsSince = document.getElementById('lp-blips-since').value;
if (blipsOn) loadBlips();
}
async function loadBlips() {
if (!map) return;
const req = ++blipReq;
try {
let url = `${API}/api/events?bbox=${currentBBox()}&has_coords=true&limit=500`;
const since = sinceToISO(blipsSince);
if (since) url += `&since=${encodeURIComponent(since)}`;
const r = await overlayFetch(url);
const evs = (await r.json()).filter(ev => ev.source_type !== 'camera');
if (req !== blipReq) return; // superseded by a newer pan/zoom
if (blipsGroup) map.removeLayer(blipsGroup);
blipsGroup = L.layerGroup(evs.map(ev => {
const col = blipColor(ev.source_type);
const icon = L.divIcon({
className: '',
html: `<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${col};box-shadow:0 0 8px ${col},0 0 2px #0f172a;border:1px solid #030609;"></span>`,
iconSize: [11, 11], iconAnchor: [5, 5],
});
return L.marker([ev.location_lat, ev.location_lon], { icon })
.bindPopup(`<div class="blip-pop">` +
`<div class="blip-src">◈ ${esc(ev.source_type)}</div>` +
`<b>${esc(ev.title || 'Untitled event')}</b>` +
`<div class="blip-time">${newsTimeAgo(ev.ingested_at)} · ${esc(ev.location_name || 'no location label')}</div>` +
(ev.url ? `<a href="${esc(ev.url)}" target="_blank" rel="noopener">source ↗</a>` : '') +
`</div>`);
}));
blipsGroup.addTo(map);
document.getElementById('lp-blips-count').textContent = evs.length.toLocaleString();
hudBlipsCount = evs.length;
syncHud();
if (document.getElementById('map-hint').textContent.includes('fire hotspots')) {
// keep the fires line dominant; append blips quietly
} else {
document.getElementById('map-hint').textContent =
`${evs.length.toLocaleString()} event blips in view`;
}
} catch(e) {
if (isAbort(e)) return;
document.getElementById('map-hint').textContent = `Blips load failed: ${e.message || e}`;
console.error('Blips load failed', e);
}
}
/* ── CRITICAL NEWS (LLM-estimated locations, critical/high only) ── */
async function toggleNewsPins() {
newsOn = document.getElementById('lp-news-on').checked;
if (newsOn) {
await loadNewsPins();
return;
}
newsReq++;
if (newsGroup && map) { map.removeLayer(newsGroup); newsGroup = null; }
hudNewsCount = null;
const countEl = document.getElementById('lp-news-count');
if (countEl) countEl.textContent = '0';
syncHud();
}
async function loadNewsPins() {
if (!map) return;
const req = ++newsReq;
try {
const url = `${API}/api/news/map?bbox=${currentBBox()}&limit=200`;
const r = await overlayFetch(url);
const data = await r.json();
if (req !== newsReq) return; // superseded by a newer pan/zoom
const items = Array.isArray(data) ? data : [];
if (newsGroup) map.removeLayer(newsGroup);
const col = '#ff2e97';
const pins = items.filter(it => Number.isFinite(Number(it.lat)) && Number.isFinite(Number(it.lon)));
newsGroup = L.layerGroup(pins.map(it => {
const icon = L.divIcon({
className: '',
html: `<span style="display:inline-block;width:10px;height:10px;background:${col};box-shadow:0 0 8px ${col},0 0 2px #0f172a;border:1px solid #030609;transform:rotate(45deg);"></span>`,
iconSize: [14, 14], iconAnchor: [7, 7],
});
return L.marker([it.lat, it.lon], { icon })
.bindPopup(`<div class="blip-pop">` +
`<div class="blip-src">◈ ${esc(it.importance || '')}${it.category ? ' · ' + esc(it.category) : ''}</div>` +
`<b>${esc(it.headline || '')}</b>` +
`<div class="blip-time">${esc(it.location_name || '')}</div>` +
(it.url ? `<a href="${esc(it.url)}" target="_blank" rel="noopener">source ↗</a>` : '') +
`</div>`);
}));
newsGroup.addTo(map);
document.getElementById('lp-news-count').textContent = pins.length.toLocaleString();
hudNewsCount = pins.length;
syncHud();
} catch(e) {
if (isAbort(e)) return;
document.getElementById('map-hint').textContent = `News pins load failed: ${e.message || e}`;
console.error('News pins load failed', e);
}
}
/* ── Live overlays (radar / alerts / WFIGS / aircraft / trains / AIS / NHC) ── */
function refreshLiveOverlays() {
if (!map) return;
if (radarOn) loadRadar();
if (thermalOn) loadThermal();
if (wxAlertsOn) loadWxAlerts();
if (perimOn) loadPerimeters();
if (incidentsOn) loadIncidents();
if (acOn) loadAircraft();
if (trainsOn) loadTrains();
if (vesselsOn) loadVessels();
if (stormsOn) loadStorms();
loadGeofences();
loadFireAircraftHits();
}
function addExtraAttrib(html) {
if (!map || !html || extraAttribs.has(html)) return;
extraAttribs.add(html);
map.attributionControl.addAttribution(html);
}
function dropLayer(ref) {
if (ref && map && map.hasLayer(ref)) map.removeLayer(ref);
return null;
}
/* Polygon/camera layers hitch at world scale. Same idea as aircraft's zoom<=3
skip: do not fetch or render these until the user is zoomed in. */
const HEAVY_MIN_ZOOM = 4;
function tooZoomedOut() { return !map || map.getZoom() <= HEAVY_MIN_ZOOM; }
function markZoom(id) {
const el = document.getElementById(id);
if (el) el.textContent = 'zoom';
}
function intersectsConus() {
if (!map) return false;
const b = map.getBounds();
const west = Math.max(b.getWest(), -125);
const east = Math.min(b.getEast(), -66);
const south = Math.max(b.getSouth(), 24);
const north = Math.min(b.getNorth(), 50);
return west < east && south < north && map.getZoom() >= 4;
}
function severityColor(sev) {
const s = String(sev || '').toLowerCase();
if (s === 'extreme' || s === 'warning') return '#ff5d5d';
if (s === 'severe') return '#fb923c';
if (s === 'moderate') return '#ffb454';
if (s === 'minor') return '#53f0a5';
return '#35e0ff';
}
function altColor(alt) {
const a = Number(alt) || 0;
if (a >= 35000) return '#e0f2fe';
if (a >= 20000) return '#7dd3fc';
if (a >= 10000) return '#38bdf8';
if (a >= 1000) return '#fbbf24';
return '#fb923c';
}
function _popVal(v) {
if (v == null || v === '' || v === 'none' || v === 'undefined') return null;
return v;
}
function pointPopup(p) {
const extra = p.extra || {};
const src = extra.src || '';
const role = extra.firefighter ? 'firefighter' : extra.role;
const badge = role
? ` <span class="role-badge ${esc(role)}">${esc(String(role).toUpperCase())}</span>`
: '';
const rows = [];
const add = (k, v) => {
const val = _popVal(v);
if (val == null) return;
rows.push(`<tr><td class="k">${esc(k)}</td><td>${esc(val)}</td></tr>`);
};
if (src === 'adsb.lol') {
add('type', extra.type);
add('aircraft', extra.desc);
add('operator', extra.ownOp);
add('reg', extra.reg);
const alt = extra.alt_baro;
add('alt', alt != null ? `${alt} ft` : null);
add('vs', extra.vs != null ? `${extra.vs} fpm` : null);
add('gs', p.speed != null ? `${p.speed} kt` : null);
add('hdg', p.heading != null ? `${Math.round(p.heading)}°` : null);
add('squawk', extra.squawk);
add('emergency', extra.emergency);
add('class', extra.emitter);
add('hex', extra.hex);
} else if (src === 'aisstream') {
add('kind', extra.kind);
add('flag', extra.country);
add('dest', extra.dest);
add('nav', extra.nav);
add('callsign', extra.callsign);
add('imo', extra.imo);
add('mmsi', extra.mmsi);
if (extra.length && extra.beam) add('size', `${extra.length} × ${extra.beam} m`);
else add('length', extra.length != null ? `${extra.length} m` : null);
add('draught', extra.draught != null ? `${extra.draught} m` : null);
add('sog', p.speed != null ? `${p.speed} kt` : null);
add('hdg', p.heading != null ? `${Math.round(p.heading)}°` : null);
add('eta', extra.eta);
} else {
Object.keys(extra).filter(k => k !== 'stations' && extra[k] != null && extra[k] !== '')
.slice(0, 8)
.forEach(k => add(k, extra[k]));
}
let photoBlock = '';
const hex = extra.hex || p.id || '';
if (src === 'adsb.lol' && (hex || extra.reg)) {
photoBlock = `<div class="ps-photo" data-hex="${esc(hex)}" data-reg="${esc(extra.reg || '')}"></div>`;
}
return `<div class="cam-pop"><b>${esc(p.label || p.id)}</b>${badge}<table>${rows.join('')}</table>${photoBlock}</div>`;
}
/* ── Planespotters.net aircraft photos ────────────────────────────────────
Lazy-loaded into the ADS-B popup. Server-side /api/aircraft/photo looks up
JSON (their ToS requires a contact User-Agent on server clients). The
thumbnail binary is loaded by the browser from their CDN — never re-hosted. */
function loadPlanePhoto(popup) {
const root = popup && (popup.getElement ? popup.getElement() : popup);
if (!root) return;
root.querySelectorAll('.ps-photo').forEach(box => {
if (box.dataset.loaded === '1' || box.dataset.loading === '1') return;
const hex = (box.dataset.hex || '').trim();
const reg = (box.dataset.reg || '').trim();
if (!hex && !reg) { box.remove(); return; }
box.dataset.loading = '1';
box.innerHTML = `<div class="ps-loading">photo…</div>`;
const bump = () => {
if (!popup || !popup._map) return;
// popup.update() re-runs bindPopup()'s factory and wipes the <img>.
if (popup._updateLayout) popup._updateLayout();
if (popup._updatePosition) popup._updatePosition();
if (popup._adjustPan) popup._adjustPan();
};
const q = hex ? `hex=${encodeURIComponent(hex)}` : `reg=${encodeURIComponent(reg)}`;
fetch(`${API}/api/aircraft/photo?${q}`, { headers: { 'Accept': 'application/json' } })
.then(r => {
if (r.status === 404) throw new Error('none');
if (!r.ok) throw new Error('http ' + r.status);
return r.json();
})
.then(ph => {
if (!ph || !ph.src) { box.remove(); bump(); return; }
const link = esc(ph.link || '');
const alt = `Photo of ${esc(reg || hex)}`;
const credit = ph.photographer ? `© ${esc(ph.photographer)}` : '';
box.dataset.loaded = '1';
box.innerHTML =
`<a class="ps-link" href="${link}" target="_blank" rel="noopener">` +
`<img class="ps-thumb" src="${esc(ph.src)}" alt="${alt}" loading="lazy"` +
(ph.width ? ` width="${esc(ph.width)}"` : '') +
(ph.height ? ` height="${esc(ph.height)}"` : '') + `></a>` +
(credit ? `<div class="ps-credit"><a href="${link}" target="_blank" rel="noopener">${credit}</a></div>` : '');
const img = box.querySelector('img');
if (img && !img.complete) img.addEventListener('load', bump, { once: true });
else bump();
})
.catch(() => { box.remove(); bump(); });
});
}
/* ── Heading-aware live-feed glyph markers ───────────────────────────────
Each feed gets a distinct SVG silhouette (pointing north = up). The glyph
is wrapped in a .hdg-glyph span whose CSS transform rotates it to the
object's heading. Icons are cached per (feed, color, heading) so thousands
of markers reuse a handful of L.divIcon instances instead of one per marker.
Rotation lives on the inner glyph span only, never the marker container,
so clustering + iconAnchor stay intact. */
const FEED_GLYPHS = {
ac: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2L13.5 7L20 8.5V10.5L13.5 11L14.5 15L17 16.5V18L12 16.8L7 18V16.5L9.5 15L10.5 11L4 10.5V8.5L10.5 7L12 2Z"/></svg>',
train: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.5 3.5h15a1.5 1.5 0 0 1 1.5 1.5v10.5h-18V5a1.5 1.5 0 0 1 1.5-1.5ZM3 15.5h18l-1.5 2.5h-15ZM6.5 6.5h3.5v3.5H6.5ZM13 6.5h3.5v3.5H13ZM7 20.5h2v1H7ZM15 20.5h2v1h-2Z"/></svg>',
vessel: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2 18 13H6Z"/><path d="M12 4v9"/><path d="M3.5 13h17l-2.5 5h-12Z"/></svg>',
};
const feedIconCache = new Map();
function sanitizeColor(c, fallback) {
const s = String(c || '').trim();
// Hex colors, rgb()/rgba() triplets, or plain alphabetic CSS keywords.
// Anything with quotes/angle brackets/url()/semicolons is rejected so a
// malicious API value can never break out of the inline style attribute.
if (/^#[0-9a-fA-F]{3,8}$/.test(s)) return s;
if (/^[a-z]{3,20}$/.test(s)) return s;
if (/^rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(,\s*[\d.]+\s*)?\)$/.test(s)) return s;
return fallback;
}
function feedIcon(feed, color, heading) {
// Normalize heading to [0,360) integer so the cache stays bounded.
// Missing/empty/NaN heading -> -1 sentinel -> glyph rendered upright.
const hnum = Number(heading);
const h = (heading === null || heading === '' || heading === undefined || !Number.isFinite(hnum))
? -1
: (Math.round(hnum % 360) + 360) % 360;
const key = `${feed}|${color}|${h}`;
let ic = feedIconCache.get(key);
if (!ic) {
const rot = h >= 0 ? `transform:rotate(${h}deg);` : '';
ic = L.divIcon({
className: '',
html: `<span class="hdg-marker"><span class="hdg-glyph hdg-${feed}" style="color:${color};${rot}">${FEED_GLYPHS[feed]}</span></span>`,
iconSize: [26, 26], iconAnchor: [13, 13],
});
feedIconCache.set(key, ic);
}
return ic;
}
function makePointMarker(p, colorFn, feed, renderer) {
if (p.lat == null || p.lon == null) return null;
const col = sanitizeColor(colorFn(p), '#35e0ff');
let m;
if (feed) {
const heading = Number(p.heading);
const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading);
m = L.marker([p.lat, p.lon], { icon });
} else {
m = L.circleMarker([p.lat, p.lon], {
radius: 5, color: col, fillColor: col, fillOpacity: 0.9, weight: 1,
renderer,
});
}
m._osintP = p;
return m.bindPopup(() => pointPopup(m._osintP || p));
}
function renderPoints(existing, points, colorFn, cluster, feed) {
const zoom = map.getZoom();
const useCluster = cluster && (zoom < 7 || points.length > 200);
const canReuse = existing && map.hasLayer(existing)
&& !!existing._osintCluster === !!useCluster
&& existing._osintById;
if (!canReuse) {
if (existing) map.removeLayer(existing);
const group = useCluster
? L.markerClusterGroup({ maxClusterRadius: 48, showCoverageOnHover: false, spiderfyOnMaxZoom: true, chunkedLoading: true })
: L.layerGroup();
group._osintCluster = !!useCluster;
group._osintById = new Map();
const renderer = pointCanvas();
points.forEach(p => {
const m = makePointMarker(p, colorFn, feed, renderer);
if (!m) return;
group.addLayer(m);
if (p.id != null) group._osintById.set(String(p.id), m);
});
group.addTo(map);
return group;
}
const group = existing;
const byId = group._osintById;
const next = new Set();
const renderer = pointCanvas();
points.forEach(p => {
if (p.lat == null || p.lon == null || p.id == null) return;
const id = String(p.id);
next.add(id);
const col = sanitizeColor(colorFn(p), '#35e0ff');
const m = byId.get(id);
if (m) {
m.setLatLng([p.lat, p.lon]);
m._osintP = p;
if (feed) m.setIcon(feedIcon(feed, col, p.heading));
else if (m.setStyle) m.setStyle({ color: col, fillColor: col });
} else {
const nm = makePointMarker(p, colorFn, feed, renderer);
if (!nm) return;
group.addLayer(nm);
byId.set(id, nm);
}
});
byId.forEach((m, id) => {
if (!next.has(id)) {
group.removeLayer(m);
byId.delete(id);
}
});
return group;
}
async function toggleRadar() {
radarOn = document.getElementById('lp-radar-on').checked;
if (radarOn) await loadRadar();
else { radarLayer = dropLayer(radarLayer); if (radarTimer) { clearInterval(radarTimer); radarTimer = null; } }
}
function setRadarOpacity(v) {
radarOpacity = v / 100;
document.getElementById('lp-radar-val').textContent = `${Math.round(v)}%`;
if (radarLayer) radarLayer.setOpacity(radarOpacity);
}
async function loadRadar() {
if (!map) return;
try {
if (!radarMeta) {
const r = await overlayFetch(`${API}/api/map/radar`);
radarMeta = await r.json();
addExtraAttrib('<a href="https://www.rainviewer.com/api.html">Weather data by RainViewer</a>');
addExtraAttrib('Iowa Environmental Mesonet');
if (!radarTimer) radarTimer = setInterval(() => { radarMeta = null; if (radarOn) loadRadar(); }, 5 * 60 * 1000);
}
const useIem = intersectsConus();
const url = useIem
? (radarMeta.iemTileUrl || 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q/{z}/{x}/{y}.png')
: (radarMeta.tileUrl);
if (!url) return;
document.getElementById('lp-radar-count').textContent = useIem ? 'IEM' : 'RV';
if (radarLayer) {
radarLayer.setUrl(url);
radarLayer.setOpacity(radarOpacity);
} else {
radarLayer = L.tileLayer(url, { opacity: radarOpacity, maxZoom: 12, maxNativeZoom: useIem ? 18 : 7, attribution: '' }).addTo(map);
}
} catch (e) {
if (isAbort(e)) return;
console.error('Radar load failed', e);
document.getElementById('lp-radar-count').textContent = 'err';
}
}
async function toggleThermal() {
thermalOn = document.getElementById('lp-thermal-on').checked;
if (thermalOn) loadThermal();
else thermalLayer = dropLayer(thermalLayer);
}
function loadThermal() {
if (!map) return;
const time = (document.getElementById('map-date') && document.getElementById('map-date').value)
|| new Date().toISOString().slice(0, 10);
const url = `https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_SNPP_Thermal_Anomalies_375m_All/default/${time}/GoogleMapsCompatible_Level9/{z}/{y}/{x}.png`;
addExtraAttrib('NASA GIBS thermal anomalies');
if (thermalLayer) thermalLayer.setUrl(url);
else thermalLayer = L.tileLayer(url, { opacity: 0.85, maxNativeZoom: 9, maxZoom: 12, attribution: '' }).addTo(map);
}
async function toggleWxAlerts() {
wxAlertsOn = document.getElementById('lp-alerts-on').checked;
if (wxAlertsOn) await loadWxAlerts();
else wxAlertsGroup = dropLayer(wxAlertsGroup);
}
async function loadWxAlerts() {
if (!map) return;
if (tooZoomedOut()) {
wxAlertsGroup = dropLayer(wxAlertsGroup);
markZoom('lp-alerts-count');
return;
}
const req = ++overlayReq.alerts;
try {
const r = await overlayFetch(`${API}/api/weather-alerts?bbox=${currentBBox()}`);
const fc = await r.json();
if (req !== overlayReq.alerts) return;
wxAlertsGroup = dropLayer(wxAlertsGroup);
const feats = fc.features || [];
wxAlertsGroup = L.geoJSON(fc, {
renderer: L.canvas({ padding: 0.5 }),
style: (f) => ({
color: severityColor((f.properties || {}).severity),
weight: 2, fillOpacity: 0.18,
}),
onEachFeature: (f, layer) => {
const p = f.properties || {};
layer.bindPopup(`<div class="blip-pop"><div class="blip-src">${esc(p.source || 'alert')}</div><b>${esc(p.event || p.headline || 'Alert')}</b><div class="blip-time">${esc(p.severity || '')} · ${esc(p.areaDesc || p.wfo || '')}</div></div>`);
},
}).addTo(map);
document.getElementById('lp-alerts-count').textContent = feats.length.toLocaleString();
addExtraAttrib('NWS / IEM storm-based warnings');
} catch (e) {
if (isAbort(e)) return;
console.error('Alerts load failed', e);
document.getElementById('lp-alerts-count').textContent = 'err';
}
}
async function togglePerimeters() {
perimOn = document.getElementById('lp-perim-on').checked;
if (perimOn) await loadPerimeters();
else perimGroup = dropLayer(perimGroup);
}
async function loadPerimeters() {
if (!map) return;
if (tooZoomedOut()) {
perimGroup = dropLayer(perimGroup);
markZoom('lp-perim-count');
return;
}
const req = ++overlayReq.perim;
try {
const r = await overlayFetch(`${API}/api/fire-perimeters?bbox=${currentBBox()}`);
const fc = await r.json();
if (req !== overlayReq.perim) return;
perimGroup = dropLayer(perimGroup);
const feats = fc.features || [];
perimGroup = L.geoJSON(fc, {
renderer: L.canvas({ padding: 0.5 }),
style: (f) => {
const acres = Number((f.properties || {}).poly_GISAcres || (f.properties || {}).attr_IncidentSize || 0);
return { color: acres > 10000 ? '#ef4444' : '#fb923c', weight: 2, fillOpacity: 0.25, fillColor: '#fb923c' };
},
onEachFeature: (f, layer) => {
const p = f.properties || {};
const name = p.poly_IncidentName || p.IncidentName || 'Fire';
const acres = p.poly_GISAcres || p.attr_IncidentSize || '?';
layer.bindPopup(`<div class="blip-pop"><b>${esc(name)}</b><div class="blip-time">${esc(acres)} acres · ${esc(p.attr_PercentContained || '')}% contained</div></div>`);
},
}).addTo(map);
document.getElementById('lp-perim-count').textContent = feats.length.toLocaleString();
addExtraAttrib('NIFC WFIGS');
} catch (e) {
if (isAbort(e)) return;
console.error('Perimeters load failed', e);
document.getElementById('lp-perim-count').textContent = 'err';
}
}
async function toggleIncidents() {
incidentsOn = document.getElementById('lp-incidents-on').checked;
if (incidentsOn) await loadIncidents();
else incidentsGroup = dropLayer(incidentsGroup);
}
async function loadIncidents() {
if (!map) return;
if (tooZoomedOut()) {
incidentsGroup = dropLayer(incidentsGroup);
markZoom('lp-incidents-count');
return;
}
const req = ++overlayReq.incidents;
try {
const r = await overlayFetch(`${API}/api/fire-incidents?bbox=${currentBBox()}`);
const pts = await r.json();
if (req !== overlayReq.incidents) return;
incidentsGroup = renderPoints(incidentsGroup, pts, () => '#ef4444', false);
document.getElementById('lp-incidents-count').textContent = pts.length.toLocaleString();
addExtraAttrib('NIFC WFIGS');
} catch (e) {
if (isAbort(e)) return;
console.error('Incidents load failed', e);
document.getElementById('lp-incidents-count').textContent = 'err';
}
}
async function toggleAircraft() {
acOn = document.getElementById('lp-ac-on').checked;
if (acOn) await loadAircraft();
else acGroup = dropLayer(acGroup);
}
async function loadAircraft() {
if (!map) return;
if (map.getZoom() <= 3) {
document.getElementById('lp-ac-count').textContent = 'zoom';
return;
}
const req = ++overlayReq.ac;
try {
const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}${dvrQs()}`);
const pts = await r.json();
if (req !== overlayReq.ac) return;
acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => acColor(p), true, 'ac');
document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
addExtraAttrib('<a href="https://www.planespotters.net/photo/api">Photo © planespotters.net</a>');
} catch (e) {
if (isAbort(e)) return;
console.error('Aircraft load failed', e);
document.getElementById('lp-ac-count').textContent = 'err';
}
}
async function toggleTrains() {
trainsOn = document.getElementById('lp-trains-on').checked;
if (trainsOn) await loadTrains();
else trainsGroup = dropLayer(trainsGroup);
}
async function loadTrains() {
if (!map) return;
const req = ++overlayReq.trains;
try {
const r = await overlayFetch(`${API}/api/trains?bbox=${currentBBox()}`);
const pts = await r.json();
if (req !== overlayReq.trains) return;
trainsGroup = renderPoints(trainsGroup, Array.isArray(pts) ? pts : [], p => (p.extra || {}).iconColor || '#c084fc', false, 'train');
document.getElementById('lp-trains-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('<a href="https://amtraker.com/about">Amtraker</a>');
} catch (e) {
if (isAbort(e)) return;
console.error('Trains load failed', e);
document.getElementById('lp-trains-count').textContent = 'err';
}
}
async function toggleVessels() {
vesselsOn = document.getElementById('lp-vessels-on').checked;
if (vesselsOn) {
lastVesselSubBox = '';
await loadVessels();
} else {
vesselsGroup = dropLayer(vesselsGroup);
clearTimeout(vesselRefollowTimer);
}
}
async function loadVessels() {
if (!map) return;
if (map.getZoom() <= 3) {
document.getElementById('lp-vessels-count').textContent = 'zoom';
return;
}
const req = ++overlayReq.vessels;
const bb = currentBBox();
// Retune the server-side AIS stream to this viewport. Skip whole-world
// boxes (zoom out / dateline wrap) so the free tier is not flooded; the
// worker coalesces retunes and caps them at 1/s upstream.
const [bw, bs, be, bn] = bb.split(',').map(Number);
const isWorld = (be - bw) >= 300 || bb.startsWith('-180.0000,-85.0000,180.0000,85.0000');
const subKey = isWorld ? '' : quantizeBBox(bb);
let retuned = false;
if (subKey && subKey !== lastVesselSubBox) {
lastVesselSubBox = subKey;
retuned = true;
fetch(`${API}/api/vessels/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bbox: subKey })
}).catch(() => {});
}
try {
const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${dvrQs()}`);
const pts = await r.json();
if (req !== overlayReq.vessels) return;
vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
const extra = p.extra || {};
if (extra.role === 'military') return '#f472b6';
if (extra.role === 'government') return '#facc15';
const sog = Number((p.speed) || 0);
return sog > 0.5 ? '#2dd4bf' : '#64748b';
}, true, 'vessel');
document.getElementById('lp-vessels-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('AISStream');
// First positions for a new zone arrive ~1-3s after the retune.
if (retuned) {
clearTimeout(vesselRefollowTimer);
vesselRefollowTimer = setTimeout(() => { if (vesselsOn) loadVessels(); }, 2200);
}
} catch (e) {
if (isAbort(e)) return;
console.error('Vessels load failed', e);
document.getElementById('lp-vessels-count').textContent = 'err';
}
}
async function toggleStorms() {
stormsOn = document.getElementById('lp-storms-on').checked;
if (stormsOn) await loadStorms();
else stormsGroup = dropLayer(stormsGroup);
}
async function loadStorms() {
if (!map) return;
const req = ++overlayReq.storms;
try {
const r = await overlayFetch(`${API}/api/storms`);
const pts = await r.json();
if (req !== overlayReq.storms) return;
stormsGroup = renderPoints(stormsGroup, Array.isArray(pts) ? pts : [], () => '#f472b6', false);
document.getElementById('lp-storms-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('NHC');
} catch (e) {
if (isAbort(e)) return;
console.error('Storms load failed', e);
document.getElementById('lp-storms-count').textContent = 'err';
}
}
/* ═══════════════ INITIAL LOAD ═══════════════ */
initNav();
initSettings();
initMarketTicker();
checkHealth();
initMap();
// Ticker lives on the map HUD — load after overlays start so it does not
// contend with the first bbox burst. Payload is slim (no article bodies).
setTimeout(() => loadNews(true), 400);
setInterval(checkHealth, 30000);
/* Phase 2: DVR slider + geofence draw (no Leaflet.Draw) + fire/aircraft hits */
function dvrQs() { return dvrTs ? `&timestamp=${encodeURIComponent(dvrTs)}` : ''; }
function dvrGoLive() {
dvrTs = null;
const bar = document.getElementById('dvr-bar');
if (bar) bar.classList.add('live');
const sl = document.getElementById('dvr-slider');
if (sl) sl.value = sl.max;
const lab = document.getElementById('dvr-label');
if (lab) lab.textContent = 'live';
if (acOn) loadAircraft();
if (vesselsOn) loadVessels();
}
function dvrScrub(v) {
if (!dvrMaxMs) { dvrGoLive(); return; }
const ms = dvrMinMs + (Number(v) / 360) * (dvrMaxMs - dvrMinMs);
dvrTs = new Date(ms).toISOString();
const bar = document.getElementById('dvr-bar');
if (bar) bar.classList.toggle('live', Number(v) >= 359);
if (Number(v) >= 359) { dvrGoLive(); return; }
const lab = document.getElementById('dvr-label');
if (lab) lab.textContent = dvrTs.slice(11, 16) + 'Z';
if (acOn) loadAircraft();
if (vesselsOn) loadVessels();
}
async function initDvrRange() {
try {
const r = await fetch(`${API}/api/tracks/range`);
const b = await r.json();
dvrMinMs = Date.parse(b.min);
dvrMaxMs = Date.parse(b.max);
if (!dvrMinMs || !dvrMaxMs) {
dvrMaxMs = Date.now();
dvrMinMs = dvrMaxMs - 6 * 3600 * 1000;
}
} catch (e) {
dvrMaxMs = Date.now();
dvrMinMs = dvrMaxMs - 6 * 3600 * 1000;
}
}
function showGeofenceToast(p) {
const box = document.getElementById('dvr-toast');
if (!box) return;
const el = document.createElement('div');
el.className = 'gf-alert';
el.textContent = `GEOFENCE ${p.geofence_name || ''} · ${p.source_kind} ${p.entity_id || ''}`;
box.prepend(el);
setTimeout(() => el.remove(), 8000);
}
function toggleGeofenceDraw() {
gfDrawOn = !gfDrawOn;
gfVerts = [];
if (gfLayer && map) { map.removeLayer(gfLayer); gfLayer = null; }
const btn = document.getElementById('gf-draw');
if (btn) btn.textContent = gfDrawOn ? 'Click map… (dbl-click close)' : 'Draw geofence';
if (!map) return;
if (gfDrawOn) {
map.getContainer().style.cursor = 'crosshair';
map.on('click', onGfClick);
map.on('dblclick', onGfClose);
} else {
map.getContainer().style.cursor = '';
map.off('click', onGfClick);
map.off('dblclick', onGfClose);
}
}
function onGfClick(e) {
if (!gfDrawOn) return;
L.DomEvent.stop(e);
gfVerts.push([e.latlng.lng, e.latlng.lat]);
if (gfLayer && map) map.removeLayer(gfLayer);
if (gfVerts.length >= 2) {
gfLayer = L.polyline(gfVerts.map(v => [v[1], v[0]]), { color: '#ff2e97', weight: 2 }).addTo(map);
}
}
async function onGfClose(e) {
if (!gfDrawOn) return;
L.DomEvent.stop(e);
if (gfVerts.length < 3) return;
const ring = gfVerts.concat([gfVerts[0]]);
const geojson = { type: 'Polygon', coordinates: [ring] };
try {
await fetch(`${API}/api/geofences`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Fence ' + new Date().toISOString().slice(11, 19), geojson }),
});
} catch (err) { console.error('geofence save failed', err); }
toggleGeofenceDraw();
loadGeofences();
}
async function loadGeofences() {
if (!map) return;
try {
const r = await overlayFetch(`${API}/api/geofences`);
const rows = await r.json();
if (gfSaved && map.hasLayer(gfSaved)) map.removeLayer(gfSaved);
const feats = (Array.isArray(rows) ? rows : []).map(f => ({
type: 'Feature', properties: { name: f.name, id: f.id }, geometry: f.geojson,
}));
gfSaved = L.geoJSON({ type: 'FeatureCollection', features: feats }, {
style: { color: '#ff2e97', weight: 2, fillOpacity: 0.08 },
}).addTo(map);
} catch (e) { if (!isAbort(e)) console.error('geofences load failed', e); }
}
async function loadFireAircraftHits() {
try {
const r = await fetch(`${API}/api/fire-aircraft`);
const rows = await r.json();
(Array.isArray(rows) ? rows : []).forEach(h => {
if (h.aircraft_hex) firefighterHex.add(String(h.aircraft_hex));
});
} catch (e) { /* empty without table */ }
}
initDvrRange();
</script>
</body>
</html>