Fetch GET /api/map/chokepoints once. Desktop buttons / phone <select> fly to Hormuz, Bab el-Mandeb, Suez, Malacca, Taiwan. Turns Vessels on, uses ?src=vesselapi on Hormuz, enables Sentinel-1 there, and never POSTs /api/vessels/subscribe outside CONUS.
3507 lines
181 KiB
HTML
3507 lines
181 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<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 {
|
||
/* 100vh first for old browsers; 100dvh shrinks with mobile URL chrome */
|
||
height: calc(100vh - var(--topbar-h) - var(--dock-h));
|
||
height: calc(100dvh - var(--topbar-h) - var(--dock-h) - env(safe-area-inset-bottom, 0px));
|
||
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));
|
||
max-width: calc(100% - 300px); /* keep off LAYERS (left) and zoom (right) */
|
||
justify-content: center;
|
||
}
|
||
.chokepoint-btns { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem; }
|
||
.chokepoint-btns .btn { padding: 0.22rem 0.42rem; font-size: 0.66rem; letter-spacing: 0.04em; }
|
||
.chokepoint-btns .btn.is-on {
|
||
border-color: var(--cyan); color: var(--cyan);
|
||
box-shadow: 0 0 8px var(--cyan-glow); background: rgba(53,224,255,0.10);
|
||
}
|
||
#chokepoint-select { display: none; }
|
||
.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.sar { background: #86efac; box-shadow: 0 0 7px #86efac; }
|
||
.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) calc(var(--dock-h) + 1.5rem);
|
||
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; overflow-wrap: anywhere; }
|
||
.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;
|
||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||
box-sizing: content-box;
|
||
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; }
|
||
.key-grid { grid-template-columns: minmax(0, 1fr); }
|
||
.key-card { min-width: 0; overflow: hidden; }
|
||
.key-form { flex-wrap: wrap; }
|
||
.key-form input {
|
||
flex: 1 1 100%;
|
||
width: 100%;
|
||
min-width: 0;
|
||
min-height: 44px;
|
||
}
|
||
.key-form .btn { min-height: 44px; min-width: 44px; }
|
||
|
||
/* Phone map chrome: one column, no shared pixels */
|
||
#view-map.view.active {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.map-toolbar {
|
||
position: relative;
|
||
left: auto;
|
||
top: auto;
|
||
transform: none;
|
||
margin: 6px 8px 4px;
|
||
width: auto;
|
||
max-width: none;
|
||
flex: 0 0 auto;
|
||
align-self: stretch;
|
||
flex-wrap: wrap;
|
||
}
|
||
.map-toolbar select {
|
||
flex: 1 1 8rem;
|
||
min-width: 0;
|
||
}
|
||
#chokepoint-label, .chokepoint-btns { display: none; }
|
||
#chokepoint-select { display: block; }
|
||
.map-stage {
|
||
flex: 1 1 auto;
|
||
height: auto;
|
||
min-height: 0;
|
||
}
|
||
#layer-panel {
|
||
top: 8px;
|
||
left: 8px;
|
||
width: min(268px, calc(100% - 68px));
|
||
max-height: calc(100% - 16px - 52px);
|
||
}
|
||
/* Collapsed by default so Africa isn't buried under the stack */
|
||
#layer-panel .lp-body { display: none; }
|
||
#layer-panel.is-open .lp-body { display: flex; }
|
||
#layer-panel:not(.is-open) .lp-caret { transform: rotate(-90deg); }
|
||
.map-hint { display: none; }
|
||
#dvr-bar {
|
||
left: 8px;
|
||
right: 8px;
|
||
bottom: 8px;
|
||
transform: none;
|
||
min-width: 0;
|
||
max-width: calc(100% - 16px);
|
||
width: calc(100% - 16px);
|
||
flex-wrap: wrap;
|
||
}
|
||
#dvr-bar input[type=range] {
|
||
min-width: 0;
|
||
min-height: 32px;
|
||
}
|
||
.leaflet-control-attribution { display: none !important; }
|
||
.leaflet-top.leaflet-right .leaflet-control-zoom {
|
||
margin-top: 8px;
|
||
margin-right: 8px;
|
||
}
|
||
}
|
||
@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>
|
||
<span class="mt-label" id="chokepoint-label">Strait</span>
|
||
<div class="chokepoint-btns" id="chokepoint-btns" role="group" aria-label="Chokepoint presets"></div>
|
||
<select id="chokepoint-select" aria-label="Chokepoint preset" onchange="applyChokepoint(this.value)">
|
||
<option value="">Strait…</option>
|
||
</select>
|
||
</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" id="sentinel-1-sar">
|
||
<div class="lp-row">
|
||
<label class="lp-name"><input type="checkbox" id="lp-sentinel-on" onchange="toggleSentinel1()"> <span class="lp-dot sar"></span> Sentinel-1 SAR (Cloud-Penetrating)</label>
|
||
<span class="lp-count" id="lp-sentinel-count">7d</span>
|
||
</div>
|
||
<div class="lp-opacity">Opacity <input type="range" id="lp-sentinel-opacity" min="5" max="100" value="80" oninput="setSentinelOpacity(this.value)"><b id="lp-sentinel-val">80%</b></div>
|
||
<div class="lp-note">Cloud-penetrating C-band. Last 7 days, viewport bbox. Tiles from same-origin TiTiler — not fetched on load.</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">Two feeds: AISStream (US live) and VesselAPI (Hormuz, 5×/day). Paste both keys on the Keys page. 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 & 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 & 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 & 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 sentinelLayer = null, sentinelOn = false, sentinelOpacity = 0.8;
|
||
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, sar:0};
|
||
let moveDebounce = null;
|
||
let overlayAbort = null;
|
||
let lastCell = '';
|
||
let lastVesselSubBox = ''; // last viewport box sent to the AIS stream
|
||
let chokepointCatalog = []; // GET /api/map/chokepoints, fetched once
|
||
let vesselSrcPref = ''; // 'vesselapi' only on Hormuz preset — no extra polls
|
||
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,
|
||
});
|
||
window.map = map;
|
||
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;
|
||
sentinelOn = document.getElementById('lp-sentinel-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();
|
||
loadChokepoints();
|
||
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() {
|
||
vesselSrcPref = '';
|
||
document.querySelectorAll('#chokepoint-btns .btn').forEach(b => {
|
||
b.classList.remove('is-on');
|
||
b.setAttribute('aria-pressed', 'false');
|
||
});
|
||
const sel = document.getElementById('chokepoint-select');
|
||
if (sel) sel.value = '';
|
||
if (map) map.setView([25, 10], 2);
|
||
}
|
||
|
||
const CHOKEPOINT_SHORT = {
|
||
hormuz: 'Hormuz',
|
||
bab_el_mandeb: 'Bab el-Mandeb',
|
||
suez: 'Suez',
|
||
malacca: 'Malacca',
|
||
taiwan: 'Taiwan',
|
||
};
|
||
async function loadChokepoints() {
|
||
try {
|
||
const r = await fetch(`${API}/api/map/chokepoints`);
|
||
const d = await r.json();
|
||
if (r.ok && Array.isArray(d.chokepoints)) chokepointCatalog = d.chokepoints;
|
||
} catch (_) { /* catalog is optional until the backend PR lands */ }
|
||
renderChokepointControls();
|
||
}
|
||
function renderChokepointControls() {
|
||
const btns = document.getElementById('chokepoint-btns');
|
||
const sel = document.getElementById('chokepoint-select');
|
||
if (!btns || !sel) return;
|
||
btns.replaceChildren();
|
||
sel.querySelectorAll('option:not([value=""])').forEach(o => o.remove());
|
||
for (const p of chokepointCatalog) {
|
||
const label = CHOKEPOINT_SHORT[p.id] || p.title || p.id;
|
||
const b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.className = 'btn';
|
||
b.dataset.id = p.id;
|
||
b.textContent = label;
|
||
b.setAttribute('aria-pressed', 'false');
|
||
b.addEventListener('click', () => applyChokepoint(p.id));
|
||
btns.appendChild(b);
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = label;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
function chokepointLeafletBounds(bbox) {
|
||
// Catalog bbox is minlat,minlon,maxlat,maxlon — Leaflet wants [[lat,lon],[lat,lon]].
|
||
const [minlat, minlon, maxlat, maxlon] = String(bbox || '').split(',').map(Number);
|
||
if (![minlat, minlon, maxlat, maxlon].every(Number.isFinite)) return null;
|
||
return [[minlat, minlon], [maxlat, maxlon]];
|
||
}
|
||
function applyChokepoint(id) {
|
||
if (!id || !map) return;
|
||
const p = chokepointCatalog.find(x => x.id === id);
|
||
if (!p) return;
|
||
document.querySelectorAll('#chokepoint-btns .btn').forEach(b => {
|
||
const on = b.dataset.id === id;
|
||
b.classList.toggle('is-on', on);
|
||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
});
|
||
const sel = document.getElementById('chokepoint-select');
|
||
if (sel && sel.value !== id) sel.value = id;
|
||
vesselSrcPref = p.vesselapi ? 'vesselapi' : '';
|
||
if (Array.isArray(p.center) && p.center.length === 2 && p.zoom) {
|
||
map.setView(p.center, p.zoom);
|
||
} else {
|
||
const bounds = chokepointLeafletBounds(p.bbox);
|
||
if (bounds) map.fitBounds(bounds, { padding: [24, 24] });
|
||
}
|
||
const vChk = document.getElementById('lp-vessels-on');
|
||
if (vChk) vChk.checked = true;
|
||
vesselsOn = true;
|
||
// Hormuz especially: turn SAR on so AIS dots sit on the radar raster. User can untick.
|
||
if (p.id === 'hormuz' || p.vesselapi) {
|
||
const sChk = document.getElementById('lp-sentinel-on');
|
||
if (sChk && !sChk.checked) {
|
||
sChk.checked = true;
|
||
sentinelOn = true;
|
||
}
|
||
}
|
||
lastCell = '';
|
||
if (vesselsOn) loadVessels();
|
||
if (sentinelOn) loadSentinel1();
|
||
}
|
||
|
||
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');
|
||
const head = panel.querySelector('.lp-head');
|
||
if (window.matchMedia('(max-width: 820px)').matches) {
|
||
panel.classList.toggle('is-open');
|
||
const open = panel.classList.contains('is-open');
|
||
head.setAttribute('aria-expanded', String(open));
|
||
return;
|
||
}
|
||
panel.classList.toggle('collapsed');
|
||
head.setAttribute('aria-expanded', String(!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 (sentinelOn) loadSentinel1();
|
||
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);
|
||
}
|
||
async function toggleSentinel1() {
|
||
sentinelOn = document.getElementById('lp-sentinel-on').checked;
|
||
if (sentinelOn) await loadSentinel1();
|
||
else {
|
||
sentinelLayer = dropLayer(sentinelLayer);
|
||
const n = document.getElementById('lp-sentinel-count');
|
||
if (n) n.textContent = '7d';
|
||
}
|
||
}
|
||
function setSentinelOpacity(v) {
|
||
sentinelOpacity = v / 100;
|
||
document.getElementById('lp-sentinel-val').textContent = `${Math.round(v)}%`;
|
||
if (sentinelLayer) sentinelLayer.setOpacity(sentinelOpacity);
|
||
}
|
||
async function loadSentinel1() {
|
||
if (!map || !sentinelOn) return;
|
||
const req = ++overlayReq.sar;
|
||
const countEl = document.getElementById('lp-sentinel-count');
|
||
const hint = document.getElementById('map-hint');
|
||
try {
|
||
if (countEl) countEl.textContent = '…';
|
||
const r = await overlayFetch(`${API}/api/map/sentinel1?bbox=${currentBBox()}`);
|
||
let body = null;
|
||
try { body = await r.json(); } catch (_) { body = null; }
|
||
if (req !== overlayReq.sar) return;
|
||
if (r.status === 404 && body && body.error === 'no_imagery') {
|
||
sentinelLayer = dropLayer(sentinelLayer);
|
||
if (hint) hint.textContent = 'No Sentinel-1 imagery for this view in the last 7 days.';
|
||
if (countEl) countEl.textContent = 'none';
|
||
return;
|
||
}
|
||
if (r.status === 429) {
|
||
sentinelLayer = dropLayer(sentinelLayer);
|
||
if (hint) hint.textContent = 'Sentinel-1 rate limited — retry later.';
|
||
if (countEl) countEl.textContent = '429';
|
||
return;
|
||
}
|
||
if (!r.ok || !body || !body.tileUrl) {
|
||
sentinelLayer = dropLayer(sentinelLayer);
|
||
if (hint) hint.textContent = 'Sentinel-1 unavailable — retry later.';
|
||
if (countEl) countEl.textContent = 'err';
|
||
return;
|
||
}
|
||
const attrib = body.attribution || '';
|
||
sentinelLayer = dropLayer(sentinelLayer);
|
||
sentinelLayer = L.tileLayer(body.tileUrl, {
|
||
opacity: sentinelOpacity,
|
||
maxZoom: 18,
|
||
attribution: attrib,
|
||
}).addTo(map);
|
||
if (countEl) countEl.textContent = String(body.polarization || 'SAR').toUpperCase();
|
||
if (hint) {
|
||
const when = body.datetime ? ` · ${body.datetime}` : '';
|
||
hint.textContent = `Sentinel-1 SAR${when} · cloud-penetrating C-band`;
|
||
}
|
||
} catch (e) {
|
||
if (isAbort(e)) return;
|
||
if (req !== overlayReq.sar) return;
|
||
sentinelLayer = dropLayer(sentinelLayer);
|
||
if (hint) hint.textContent = 'Sentinel-1 unavailable — retry later.';
|
||
if (countEl) countEl.textContent = 'err';
|
||
}
|
||
}
|
||
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;
|
||
// AISStream subscribe retunes the US feed. Never fire it for Gulf/strait
|
||
// boxes or the free stream leaves CONUS. VesselAPI is a cached poll.
|
||
if (subKey && subKey !== lastVesselSubBox && intersectsConus()) {
|
||
lastVesselSubBox = subKey;
|
||
retuned = true;
|
||
fetch(`${API}/api/vessels/subscribe`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bbox: subKey })
|
||
}).catch(() => {});
|
||
}
|
||
try {
|
||
const srcQs = vesselSrcPref ? `&src=${encodeURIComponent(vesselSrcPref)}` : '';
|
||
const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${srcQs}${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();
|
||
if (window.matchMedia('(max-width: 820px)').matches) {
|
||
const lpHead = document.querySelector('#layer-panel .lp-head');
|
||
if (lpHead) lpHead.setAttribute('aria-expanded', 'false');
|
||
}
|
||
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 ? `×tamp=${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>
|