Satellite-first chrome: daily VIIRS preferred, layer rail collapsed on load, geofence after basemap, FIRMS-only default overlays, hide MKT standby ticker, Strait select instead of buttons, IBM Plex instead of Orbitron, news as 6px circleMarkers.
4548 lines
227 KiB
HTML
4548 lines
227 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//</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=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&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: 32px; /* one NEWS ticker; MKT hidden until live */
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
html, body { height: 100%; }
|
||
body {
|
||
font-family: 'IBM Plex Sans', '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: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
|
||
.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: 'IBM Plex Sans', 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: 'IBM Plex Sans', 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: 'IBM Plex Sans', 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: 'IBM Plex 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: 'IBM Plex 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, .lp-count, .hud-chip b, #utc-clock, #dvr-label {
|
||
font-variant-numeric: tabular-nums;
|
||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||
}
|
||
.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: 'IBM Plex Sans', 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: 'IBM Plex Sans', 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: none; }
|
||
#chokepoint-select { display: inline-block; min-width: 8rem; }
|
||
.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: 'IBM Plex 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: 'IBM Plex 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; }
|
||
.hdg-emerg {
|
||
animation: emerg-pulse 1.15s ease-in-out infinite;
|
||
}
|
||
@keyframes emerg-pulse {
|
||
0%, 100% { filter: drop-shadow(0 0 4px #ff5d5d); }
|
||
50% { filter: drop-shadow(0 0 12px #ff5d5d) drop-shadow(0 0 18px #ff5d5d); }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.hdg-emerg, .role-badge.emergency { animation: none; }
|
||
}
|
||
|
||
/* ── 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: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex 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: min(320px, calc(100% - 24px)); max-width: 320px;
|
||
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));
|
||
}
|
||
#layer-panel.collapsed { width: auto; max-width: 320px; }
|
||
.lp-head {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--line);
|
||
font-family: 'IBM Plex Sans', 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(--cyan); box-shadow: 0 0 7px var(--cyan); }
|
||
.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.mil { background: #f472b6; box-shadow: 0 0 7px #f472b6; }
|
||
.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-dot.night {
|
||
background: linear-gradient(90deg, #0b1c33 50%, #ffb454 50%);
|
||
box-shadow: 0 0 7px #64748b;
|
||
}
|
||
.lp-dot.conflicts { background: #ff2a6d; box-shadow: 0 0 7px #ff2a6d; }
|
||
.lp-count { font-family: 'IBM Plex 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: none; align-items: center; gap: 0.4rem; font-size: 0.62rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.lp-layer:has(.lp-name input[type="checkbox"]:checked) .lp-opacity { display: flex; }
|
||
.lp-opacity input[type="range"] { flex: 1; accent-color: var(--cyan); height: 4px; cursor: pointer; }
|
||
.lp-opacity b { color: var(--text); font-family: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex Sans', 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; }
|
||
.gf-list { display: flex; flex-direction: column; gap: 0.22rem; max-height: 9.5rem; overflow-y: auto; }
|
||
.gf-item { display: flex; justify-content: space-between; align-items: center; gap: 0.35rem; font-size: 0.64rem; flex-wrap: wrap; }
|
||
.gf-item.selected { outline: 1px solid var(--cyan); background: rgba(53,224,255,0.08); border-radius: 3px; }
|
||
.gf-item .gf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); background: none; border: 0; font: inherit; cursor: pointer; text-align: left; flex: 1 1 6rem; min-width: 0; padding: 0.12rem 0; }
|
||
.gf-item .gf-name:hover { color: var(--cyan); }
|
||
.gf-item .gf-actions { display: flex; align-items: center; gap: 0.28rem; flex: 0 0 auto; }
|
||
.gf-item input.gf-active { accent-color: var(--cyan); cursor: pointer; width: 14px; height: 14px; }
|
||
.gf-del, .gf-go, .gf-ren { background: transparent; border: 1px solid var(--magenta); color: var(--magenta); font-family: 'IBM Plex Mono', monospace; font-size: 0.58rem; letter-spacing: 0.08em; text-transform: uppercase; padding: 0.12rem 0.35rem; border-radius: 3px; cursor: pointer; }
|
||
.gf-go, .gf-ren { border-color: var(--cyan); color: var(--cyan); }
|
||
.gf-del:hover { background: rgba(255,46,151,0.16); }
|
||
.gf-go:hover, .gf-ren:hover { background: rgba(53,224,255,0.14); }
|
||
.gf-draw-row { flex-wrap: wrap; gap: 0.35rem; }
|
||
#gf-finish, #gf-cancel { min-height: 32px; }
|
||
#gf-draw-bar {
|
||
position: absolute; left: 12px; bottom: 52px; z-index: 530;
|
||
display: flex; gap: 0.4rem; align-items: center;
|
||
background: rgba(6,11,20,0.86); border: 1px solid var(--line-hi);
|
||
border-radius: 4px; padding: 0.22rem 0.4rem;
|
||
}
|
||
#gf-draw-bar[hidden] { display: none !important; }
|
||
.gf-inbox { display: flex; flex-direction: column; gap: 0.18rem; max-height: 8rem; overflow-y: auto; margin-top: 0.28rem; }
|
||
.gf-hit {
|
||
cursor: pointer; font-size: 0.58rem; line-height: 1.35; color: var(--text);
|
||
padding: 0.22rem 0.28rem; border: 1px solid var(--line); border-radius: 3px;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
}
|
||
.gf-hit:hover, .gf-hit:focus { border-color: var(--cyan); outline: none; background: rgba(53,224,255,0.08); }
|
||
.gf-hit .gf-hit-meta { color: var(--muted); }
|
||
|
||
/* ── Keyboard cheat sheet (centered; keep off LAYERS / zoom / dock) ── */
|
||
.cheat-sheet {
|
||
position: absolute; top: 50%; left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 640;
|
||
width: min(420px, calc(100% - 96px));
|
||
max-height: calc(100% - 88px);
|
||
overflow: auto;
|
||
background: rgba(6,11,20,0.94); backdrop-filter: blur(10px);
|
||
border: 1px solid var(--line-hi); border-radius: 6px;
|
||
box-shadow: 0 0 28px rgba(53,224,255,0.18);
|
||
padding: 0.85rem 1rem 1rem;
|
||
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));
|
||
}
|
||
.cheat-sheet[hidden] { display: none !important; }
|
||
.cheat-sheet h3 {
|
||
font-family: 'IBM Plex Sans', sans-serif; font-size: 0.72rem; letter-spacing: 0.16em;
|
||
color: var(--cyan); margin: 0 0 0.65rem; text-transform: uppercase;
|
||
}
|
||
.cheat-sheet dl { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.85rem; margin: 0; }
|
||
.cheat-sheet dt kbd {
|
||
display: inline-block; min-width: 1.4em; text-align: center;
|
||
border: 1px solid var(--line-hi); border-radius: 3px; padding: 0.12rem 0.4rem;
|
||
color: var(--cyan); font-size: 0.72rem; background: var(--bg-1);
|
||
}
|
||
.cheat-sheet dd { margin: 0; font-size: 0.78rem; color: var(--muted); }
|
||
#swpc-chip { color: var(--text); letter-spacing: 0.08em; }
|
||
#swpc-chip.swpc-storm { color: var(--amber); text-shadow: 0 0 8px rgba(255,180,84,0.45); }
|
||
/* ── Place dossier (“What’s here?”) ── */
|
||
#place-dossier {
|
||
position: absolute; top: 58px; right: 54px; z-index: 650;
|
||
width: 280px; max-height: calc(100% - 90px); overflow: hidden;
|
||
background: rgba(6,11,20,0.90); 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: none; 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));
|
||
}
|
||
#place-dossier.open { display: flex; }
|
||
.pd-head {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 0.55rem 0.95rem 0.55rem 0.85rem; border-bottom: 1px solid var(--line);
|
||
font-family: 'IBM Plex Sans', 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);
|
||
background: linear-gradient(180deg, rgba(53,224,255,0.07), transparent);
|
||
}
|
||
.pd-close {
|
||
background: transparent; border: 1px solid var(--line); color: var(--muted);
|
||
font-family: 'IBM Plex Mono', monospace; font-size: 0.78rem;
|
||
width: 28px; height: 28px; border-radius: 4px; cursor: pointer; line-height: 1;
|
||
flex-shrink: 0; margin-right: 2px;
|
||
}
|
||
.pd-close:hover { border-color: var(--cyan); color: var(--cyan); }
|
||
.pd-body { padding: 0.55rem 0.75rem 0.7rem; overflow-y: auto; display: flex; flex-direction: column; gap: 0.55rem; min-height: 0; flex: 1; }
|
||
.pd-status { font-family: 'IBM Plex Mono', monospace; font-size: 0.66rem; color: var(--muted); }
|
||
.pd-status.err { color: var(--red); }
|
||
.pd-coords { font-family: 'IBM Plex Mono', monospace; font-size: 0.66rem; color: var(--cyan); letter-spacing: 0.04em; }
|
||
.pd-name { font-family: 'IBM Plex Sans', sans-serif; font-weight: 700; font-size: 0.92rem; color: var(--text); line-height: 1.25; }
|
||
.pd-addr { font-size: 0.72rem; color: var(--muted); line-height: 1.4; }
|
||
.pd-sec {
|
||
font-family: 'IBM Plex Sans', sans-serif; font-size: 0.58rem; letter-spacing: 0.12em;
|
||
text-transform: uppercase; color: var(--muted); margin-top: 0.15rem;
|
||
}
|
||
.pd-list { display: flex; flex-direction: column; gap: 0.18rem; }
|
||
.pd-item {
|
||
display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;
|
||
background: transparent; border: 0; border-left: 2px solid var(--line);
|
||
color: var(--text); text-align: left; cursor: pointer; padding: 0.22rem 0.35rem;
|
||
font-family: 'IBM Plex Sans', sans-serif; font-size: 0.78rem; font-weight: 600;
|
||
}
|
||
.pd-item:hover, .pd-item:focus-visible { border-left-color: var(--cyan); color: var(--cyan); background: rgba(53,224,255,0.06); }
|
||
.pd-item .k { text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.58rem; color: var(--muted); font-family: 'IBM Plex Mono', monospace; flex-shrink: 0; }
|
||
.pd-item .d { font-family: 'IBM Plex Mono', monospace; font-size: 0.62rem; color: var(--cyan); white-space: nowrap; }
|
||
.pd-empty { font-size: 0.72rem; color: var(--muted); font-style: italic; }
|
||
.pd-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: 'IBM Plex 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: 'IBM Plex 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); }
|
||
.role-badge.emergency {
|
||
color: #ff5d5d; border: 1px solid #ff5d5d; background: rgba(255,93,93,0.16);
|
||
animation: emerg-pulse 1.4s ease-in-out infinite;
|
||
}
|
||
.blip-pop { min-width: 200px; max-width: 260px; }
|
||
.blip-pop .blip-src { font-family: 'IBM Plex 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: 'IBM Plex Mono', monospace; margin: 0.2rem 0 0.3rem; }
|
||
.blip-pop .blip-desc { font-size: 0.72rem; color: var(--text); line-height: 1.35; margin-top: 0.15rem; }
|
||
|
||
/* ── 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: 'IBM Plex Sans', 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: 'IBM Plex Sans', 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: 'IBM Plex Sans', 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: 'IBM Plex Sans', 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: 'IBM Plex Sans', 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: 'IBM Plex 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: 'IBM Plex Sans', 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: 'IBM Plex Mono', monospace; }
|
||
.ns-time { font-size: 0.68rem; color: var(--muted); font-family: 'IBM Plex 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: 'IBM Plex Sans', 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: 'IBM Plex 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: 'IBM Plex Sans', 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: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex Mono', monospace; font-size: 0.86rem; font-weight: 600; color: var(--cyan); word-break: break-all; }
|
||
.key-status { font-size: 0.68rem; font-family: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex Sans', 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(--cyan); color: #02131c; }
|
||
.ticker.market[hidden] { display: none !important; }
|
||
.dock.news-only { height: 32px; }
|
||
.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: 'IBM Plex Mono', monospace; color: var(--cyan); font-size: 0.74rem; }
|
||
.tick-item .price { font-family: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex 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: 'IBM Plex 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 .b-tag.medium { background: var(--cyan); color: #041018; }
|
||
.tick-item .b-tag.low { background: var(--muted); color: #041018; }
|
||
.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: 'IBM Plex 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: 'IBM Plex Sans', 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: 'IBM Plex Sans', 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: 'IBM Plex 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: 32px; }
|
||
.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;
|
||
}
|
||
#gf-draw-bar {
|
||
left: 8px;
|
||
bottom: 56px;
|
||
}
|
||
#gf-finish, #gf-cancel, #gf-draw-bar .btn, .gf-go, .gf-del, .gf-ren {
|
||
min-height: 32px;
|
||
}
|
||
.leaflet-control-attribution { display: none !important; }
|
||
.leaflet-top.leaflet-right .leaflet-control-zoom {
|
||
margin-top: 8px;
|
||
margin-right: 8px;
|
||
}
|
||
#place-dossier {
|
||
top: auto;
|
||
right: 8px;
|
||
left: 8px;
|
||
bottom: 56px;
|
||
width: auto;
|
||
max-height: 36vh;
|
||
}
|
||
}
|
||
@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></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>
|
||
<span class="sep hide-sm" id="swpc-sep" hidden>|</span>
|
||
<span class="hide-sm" id="swpc-chip" hidden title="NOAA SWPC planetary K-index">Kp —</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" hidden role="group" aria-hidden="true"></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" class="collapsed">
|
||
<div class="lp-head" onclick="toggleLayerPanel()" role="button" tabindex="0" aria-expanded="false"
|
||
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 gf-draw-row">
|
||
<button class="btn" id="gf-draw" type="button" onclick="toggleGeofenceDraw()" title="Tap vertices, then Finish">Draw geofence</button>
|
||
<button class="btn" id="gf-finish" type="button" hidden onclick="onGfClose(event)">Finish</button>
|
||
<button class="btn" id="gf-cancel" type="button" hidden onclick="cancelGeofenceDraw()">Cancel</button>
|
||
</div>
|
||
<div id="gf-list" class="gf-list"></div>
|
||
<div id="gf-inbox" class="gf-inbox" aria-label="Geofence alerts"></div>
|
||
</div>
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name" title="Sun terminator. No API."><input type="checkbox" id="lp-terminator-on" onchange="toggleTerminator()"> <span class="lp-dot night"></span> Day/Night</label>
|
||
<span class="lp-count" id="lp-terminator-count">off</span>
|
||
</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" 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" title="Geolocated ingest events"><input type="checkbox" id="lp-blips-on" 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>
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name" title="LLM-estimated locations from the 15-min brief"><input type="checkbox" id="lp-news-on" onchange="toggleNewsPins()"> <span class="lp-dot news"></span> Critical News</label>
|
||
<span class="lp-count" id="lp-news-count">0</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name" title="IEM NEXRAD over CONUS, RainViewer elsewhere"><input type="checkbox" id="lp-radar-on" 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>
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name"><input type="checkbox" id="lp-alerts-on" 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" title="C-band SAR, last 7 days, viewport"><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>
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name"><input type="checkbox" id="lp-perim-on" 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" title="MIL / firefighting / emergency squawk styling"><input type="checkbox" id="lp-ac-on" onchange="toggleAircraft()"> <span class="lp-dot flights"></span> Aircraft</label>
|
||
<span class="lp-count" id="lp-ac-count">0</span>
|
||
</div>
|
||
<div class="lp-row" id="lp-ac-mil-row" hidden>
|
||
<label class="lp-name"><input type="checkbox" id="lp-ac-mil-on" onchange="toggleAircraftMil()"> <span class="lp-dot mil"></span> MIL</label>
|
||
<span class="lp-count" id="lp-ac-mil-count"></span>
|
||
</div>
|
||
</div>
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name" title="Amtraker · ODC-By"><input type="checkbox" id="lp-trains-on" onchange="toggleTrains()"> <span class="lp-dot trains"></span> Trains</label>
|
||
<span class="lp-count" id="lp-trains-count">0</span>
|
||
</div>
|
||
</div>
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name" title="AISStream US live · VesselAPI Hormuz"><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>
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name"><input type="checkbox" id="lp-storms-on" 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-layer" id="conflicts-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name" title="Curated theatres · news counts"><input type="checkbox" id="lp-conflicts-on" onchange="toggleConflicts()"> <span class="lp-dot conflicts"></span> Conflicts</label>
|
||
<span class="lp-count" id="lp-conflicts-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>
|
||
|
||
<aside id="place-dossier" role="dialog" aria-labelledby="pd-title" aria-hidden="true">
|
||
<div class="pd-head">
|
||
<span id="pd-title">What’s here?</span>
|
||
<button type="button" class="pd-close" id="pd-close" aria-label="Close place dossier">✕</button>
|
||
</div>
|
||
<div class="pd-body">
|
||
<div class="pd-status" id="pd-status">Right-click the map (long-press on touch).</div>
|
||
<div class="pd-coords" id="pd-coords"></div>
|
||
<div class="pd-name" id="pd-name"></div>
|
||
<div class="pd-addr" id="pd-addr"></div>
|
||
<div class="pd-sec">Nearby · 5 km</div>
|
||
<div class="pd-list" id="pd-nearby"></div>
|
||
<div class="pd-note">Loaded overlays only — no world refetch. Nominatim reverse via GET /api/place.</div>
|
||
</div>
|
||
</aside>
|
||
<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="cheat-sheet" class="cheat-sheet" hidden role="dialog" aria-label="Keyboard shortcuts">
|
||
<h3>Shortcuts</h3>
|
||
<dl>
|
||
<dt><kbd>?</kbd></dt><dd>This cheat sheet</dd>
|
||
<dt><kbd>L</kbd></dt><dd>Toggle layer rail</dd>
|
||
<dt><kbd>R</kbd></dt><dd>Reset map view</dd>
|
||
<dt><kbd>Esc</kbd></dt><dd>Close panels</dd>
|
||
</dl>
|
||
<p class="cheat-note" style="margin-top:0.7rem">Camera / HLS thumbs at zoom 12+. F and S are left unbound.</p>
|
||
</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="gf-draw-bar" hidden>
|
||
<button class="btn" type="button" onclick="onGfClose(event)">Finish</button>
|
||
<button class="btn" type="button" onclick="cancelGeofenceDraw()">Cancel</button>
|
||
</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" style="accent-color:var(--cyan)"> Cameras</label>
|
||
<label style="display:flex;align-items:center;gap:0.4rem"><input type="checkbox" id="set-blips-on" 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 news-only">
|
||
<div class="ticker market" hidden aria-hidden="true" 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 = '—';
|
||
}
|
||
}
|
||
|
||
/* ═══════════════ HUD keys + SWPC + /api/stats rail ═══════════════ */
|
||
const STATS_POLL_MS = 30000;
|
||
const SWPC_POLL_MS = 90000;
|
||
const CAM_THUMB_MIN_ZOOM = 12;
|
||
let statsRailActive = false;
|
||
let statsPollDisabled = false;
|
||
const STATS_COUNT_IDS = {
|
||
aircraft: 'lp-ac-count',
|
||
vessels: 'lp-vessels-count',
|
||
trains: 'lp-trains-count',
|
||
cameras: 'lp-cams-count',
|
||
fires: 'lp-fires-count',
|
||
alerts: 'lp-alerts-count',
|
||
};
|
||
|
||
const COUNT_HUD = {
|
||
'lp-fires-count': 'hud-fires-n',
|
||
'lp-cams-count': 'hud-cams-n',
|
||
'lp-blips-count': 'hud-blips-n',
|
||
'lp-news-count': 'hud-news-n',
|
||
};
|
||
function setLayerCount(id, value) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = value;
|
||
const hid = COUNT_HUD[id];
|
||
if (hid) {
|
||
const h = document.getElementById(hid);
|
||
if (h) h.textContent = value;
|
||
}
|
||
}
|
||
|
||
async function pollLayerStats() {
|
||
if (statsPollDisabled) return;
|
||
try {
|
||
const r = await fetch(`${API}/api/stats`);
|
||
if (r.status === 404) { statsPollDisabled = true; statsRailActive = false; return; }
|
||
if (!r.ok) return;
|
||
const s = await r.json();
|
||
if (!s || typeof s !== 'object') return;
|
||
statsRailActive = true;
|
||
const skipIfOn = {
|
||
fires: firesOn,
|
||
cameras: camsOn,
|
||
aircraft: acOn,
|
||
vessels: vesselsOn,
|
||
trains: trainsOn,
|
||
alerts: wxAlertsOn,
|
||
};
|
||
Object.keys(STATS_COUNT_IDS).forEach((k) => {
|
||
if (skipIfOn[k]) return;
|
||
const n = s[k];
|
||
if (typeof n !== 'number' || !isFinite(n)) return;
|
||
setLayerCount(STATS_COUNT_IDS[k], n.toLocaleString());
|
||
});
|
||
} catch (e) {
|
||
// Never block the map on stats failure — keep last viewport lengths.
|
||
}
|
||
}
|
||
|
||
function hideSwpcChip() {
|
||
const chip = document.getElementById('swpc-chip');
|
||
const sep = document.getElementById('swpc-sep');
|
||
if (chip) { chip.hidden = true; chip.classList.remove('swpc-storm'); }
|
||
if (sep) sep.hidden = true;
|
||
}
|
||
async function pollSwpc() {
|
||
const chip = document.getElementById('swpc-chip');
|
||
const sep = document.getElementById('swpc-sep');
|
||
if (!chip) return;
|
||
try {
|
||
const r = await fetch('https://services.swpc.noaa.gov/json/planetary_k_index_1m.json');
|
||
if (!r.ok) { hideSwpcChip(); return; }
|
||
const rows = await r.json();
|
||
const last = Array.isArray(rows) && rows.length ? rows[rows.length - 1] : null;
|
||
const kp = last && last.kp_index;
|
||
if (kp == null || !isFinite(Number(kp))) { hideSwpcChip(); return; }
|
||
let label = 'Kp ' + Number(kp);
|
||
try {
|
||
const fr = await fetch('https://services.swpc.noaa.gov/json/goes/primary/xray-flares-latest.json');
|
||
if (fr.ok) {
|
||
const flares = await fr.json();
|
||
const f = Array.isArray(flares) ? flares[0] : flares;
|
||
const cls = f && (f.max_class || f.current_class || f['class']);
|
||
if (cls && /^[MX]/i.test(String(cls))) label += ' · ' + cls;
|
||
}
|
||
} catch (e) { /* flares optional */ }
|
||
try {
|
||
const ar = await fetch('https://services.swpc.noaa.gov/products/alerts.json');
|
||
if (ar.ok) {
|
||
const alerts = await ar.json();
|
||
const n = Array.isArray(alerts) ? alerts.length : 0;
|
||
if (n) chip.title = 'NOAA SWPC · ' + n + ' alert(s)';
|
||
}
|
||
} catch (e) { /* alerts optional */ }
|
||
chip.textContent = label;
|
||
chip.hidden = false;
|
||
if (sep) sep.hidden = false;
|
||
chip.classList.toggle('swpc-storm', Number(kp) >= 5);
|
||
} catch (e) {
|
||
hideSwpcChip();
|
||
}
|
||
}
|
||
|
||
function hudTypingTarget(el) {
|
||
if (!el) return false;
|
||
const tag = (el.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||
return !!el.isContentEditable;
|
||
}
|
||
function toggleCheatSheet(force) {
|
||
const el = document.getElementById('cheat-sheet');
|
||
if (!el) return;
|
||
const open = force === undefined ? el.hidden : !!force;
|
||
el.hidden = !open;
|
||
}
|
||
function closeLayerPanel() {
|
||
const panel = document.getElementById('layer-panel');
|
||
if (!panel) return;
|
||
const head = panel.querySelector('.lp-head');
|
||
if (window.matchMedia('(max-width: 820px)').matches) {
|
||
if (panel.classList.contains('is-open')) {
|
||
panel.classList.remove('is-open');
|
||
if (head) head.setAttribute('aria-expanded', 'false');
|
||
}
|
||
return;
|
||
}
|
||
if (!panel.classList.contains('collapsed')) {
|
||
panel.classList.add('collapsed');
|
||
if (head) head.setAttribute('aria-expanded', 'false');
|
||
}
|
||
}
|
||
function closeHudPanels() {
|
||
toggleCheatSheet(false);
|
||
closeNav();
|
||
closeLayerPanel();
|
||
if (map) map.closePopup();
|
||
}
|
||
function initHudKeys() {
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||
if (e.key === 'Escape') {
|
||
closeHudPanels();
|
||
return;
|
||
}
|
||
if (hudTypingTarget(e.target)) return;
|
||
if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
|
||
e.preventDefault();
|
||
const sheet = document.getElementById('cheat-sheet');
|
||
toggleCheatSheet(sheet ? sheet.hidden : true);
|
||
return;
|
||
}
|
||
if (e.key === 'l' || e.key === 'L') {
|
||
e.preventDefault();
|
||
toggleLayerPanel();
|
||
return;
|
||
}
|
||
if (e.key === 'r' || e.key === 'R') {
|
||
e.preventDefault();
|
||
mapResetView();
|
||
}
|
||
// F / S intentionally unbound — Osiris conflicts (flights vs fullscreen / search).
|
||
});
|
||
}
|
||
|
||
/* ═══════════════ 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.
|
||
const el = document.querySelector('.ticker.market');
|
||
if (el) { el.hidden = true; el.setAttribute('aria-hidden', 'true'); }
|
||
const dock = document.querySelector('.dock');
|
||
if (dock) dock.classList.add('news-only');
|
||
document.documentElement.style.setProperty('--dock-h', '32px');
|
||
}
|
||
|
||
/* ═══════════════ NEWS FEED + TICKER ═══════════════ */
|
||
let newsInterval = null;
|
||
const NEWS_REFRESH_MS = 180000; // 3-min ticker/brief poll
|
||
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' : imp === 'high' ? 'HIGH' : imp === 'medium' ? 'MED' : 'LOW';
|
||
const tagClass = (imp === 'critical' || imp === 'high' || imp === 'medium' || imp === 'low') ? imp : 'high';
|
||
const loc = (t.location_name || '').trim();
|
||
const inner =
|
||
`<span class="b-tag ${tagClass}">${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 = false;
|
||
document.getElementById('set-blips-on').checked = false;
|
||
document.getElementById('set-fires-on').checked = true;
|
||
applyMapSettings({firesSince:'24h', blipsSince:'24h', camsOn:false, blipsOn:false, firesOn:true}, true);
|
||
document.getElementById('map-saved').textContent = 'Defaults restored.';
|
||
}
|
||
function applyMapSettings(prefs, live) {
|
||
prefs = prefs || {};
|
||
const fs = document.getElementById('lp-fires-since');
|
||
const bs = document.getElementById('lp-blips-since');
|
||
if (fs && prefs.firesSince) fs.value = prefs.firesSince;
|
||
if (bs && prefs.blipsSince) bs.value = prefs.blipsSince;
|
||
const cf = document.getElementById('lp-cams-on');
|
||
const bf = document.getElementById('lp-blips-on');
|
||
const ff = document.getElementById('lp-fires-on');
|
||
if (cf && Object.prototype.hasOwnProperty.call(prefs, 'camsOn')) cf.checked = !!prefs.camsOn;
|
||
if (bf && Object.prototype.hasOwnProperty.call(prefs, 'blipsOn')) bf.checked = !!prefs.blipsOn;
|
||
if (ff && Object.prototype.hasOwnProperty.call(prefs, 'firesOn')) ff.checked = !!prefs.firesOn;
|
||
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 && firesOn) loadFires();
|
||
if (prefs.camsOn && camsOn) loadCams();
|
||
if (prefs.blipsOn && blipsOn) loadBlips();
|
||
}
|
||
if (prefs.firesSince) firesSince = prefs.firesSince;
|
||
if (prefs.blipsSince) blipsSince = prefs.blipsSince;
|
||
if (Object.prototype.hasOwnProperty.call(prefs, 'camsOn')) camsOn = !!prefs.camsOn;
|
||
if (Object.prototype.hasOwnProperty.call(prefs, 'blipsOn')) blipsOn = !!prefs.blipsOn;
|
||
if (Object.prototype.hasOwnProperty.call(prefs, 'firesOn')) firesOn = !!prefs.firesOn;
|
||
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 = false, radarOpacity = 0.7, radarMeta = null, radarTimer = null;
|
||
let thermalLayer = null, thermalOn = false;
|
||
let sentinelLayer = null, sentinelOn = false, sentinelOpacity = 0.8;
|
||
let sentinelItemId = null, sentinelBounds = null;
|
||
let wxAlertsGroup = null, wxAlertsOn = false;
|
||
let perimGroup = null, perimOn = false;
|
||
let incidentsGroup = null, incidentsOn = false;
|
||
let acGroup = null, acOn = false, acMilOn = false, acMilSupported = false;
|
||
let trainsGroup = null, trainsOn = false;
|
||
let vesselsGroup = null, vesselsOn = false;
|
||
let stormsGroup = null, stormsOn = false;
|
||
let conflictsGroup = null, conflictsOn = false, conflictsCache = null;
|
||
let overlayReq = {ac:0, trains:0, vessels:0, alerts:0, perim:0, incidents:0, storms:0, sar:0, conflicts: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
|
||
let lastCams = [], lastFires = [], lastAircraft = [], lastVessels = [], lastAlerts = [];
|
||
let placeMarker = null, placeReq = 0, placeLongPress = null;
|
||
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;
|
||
let gfWatchIds = [], gfSelectedId = null, gfLayerById = {}, gfNameById = {};
|
||
let gfAtReq = 0, dvrRangeFromTracks = false, gfAtOk = true;
|
||
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() }));
|
||
sendWatchGeofences();
|
||
}
|
||
function sendWatchGeofences() {
|
||
if (!liveWs || liveWs.readyState !== 1) return;
|
||
liveWs.send(JSON.stringify({ type: 'watch_geofences', ids: gfWatchIds }));
|
||
}
|
||
function dropLivePoint(group, id) {
|
||
if (!group || !group._osintById || id == null) return;
|
||
const key = String(id);
|
||
const m = group._osintById.get(key);
|
||
if (!m) return;
|
||
group.removeLayer(m);
|
||
group._osintById.delete(key);
|
||
}
|
||
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, feedPulse(feed, p)));
|
||
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) {
|
||
if (!acVisible(p)) {
|
||
dropLivePoint(acGroup, p.id);
|
||
return;
|
||
}
|
||
acGroup = upsertLivePoint(acGroup, p, q => acColor(q), 'ac');
|
||
} else if (kind === 'geofence_alert') {
|
||
showGeofenceToast(p);
|
||
pushGfInbox(p);
|
||
} else if (kind === 'fire_aircraft') {
|
||
firefighterHex.add(String(p.aircraft_hex || ''));
|
||
if (acOn && p.aircraft_lat != null) {
|
||
const row = {
|
||
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' },
|
||
};
|
||
if (!acVisible(row)) dropLivePoint(acGroup, row.id);
|
||
else acGroup = upsertLivePoint(acGroup, row, q => acColor(q), 'ac');
|
||
}
|
||
}
|
||
}
|
||
const EMERG_SQUAWK = new Set(['7700', '7600', '7500']);
|
||
function acIsEmergency(p) {
|
||
const extra = (p && p.extra) || {};
|
||
const em = String(extra.emergency || '').toLowerCase();
|
||
if (em && em !== 'none') return true;
|
||
const sq = String(extra.squawk || '').replace(/\s/g, '');
|
||
return EMERG_SQUAWK.has(sq);
|
||
}
|
||
function acVisible(p) {
|
||
if (!acMilOn) return true;
|
||
return ((p && p.extra) || {}).role === 'military';
|
||
}
|
||
function noteMilSupport(pts) {
|
||
if (acMilSupported) return;
|
||
if (!Array.isArray(pts) || !pts.some(q => q && q.extra && q.extra.role)) return;
|
||
acMilSupported = true;
|
||
const row = document.getElementById('lp-ac-mil-row');
|
||
if (row) row.hidden = false;
|
||
}
|
||
function acColor(p) {
|
||
const extra = p.extra || {};
|
||
const t = String(extra.type || '').toUpperCase();
|
||
if (acIsEmergency(p)) 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(); sendWatchGeofences(); };
|
||
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 === 'VIIRS_SNPP_CorrectedReflectance_TrueColor')
|
||
|| mapLayers.find(l => l.id === 'MODIS_Terra_CorrectedReflectance_TrueColor')
|
||
|| 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);
|
||
if (camThumbsAllowed()) startHlsFrom(e.popup.getElement());
|
||
})
|
||
.catch(() => {
|
||
const ph = box.querySelector('.thumb.placeholder');
|
||
if (ph) ph.textContent = 'preview unavailable';
|
||
});
|
||
return;
|
||
}
|
||
if (camThumbsAllowed()) 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('contextmenu', (e) => {
|
||
if (e.originalEvent) e.originalEvent.preventDefault();
|
||
if (typeof gfDrawOn !== 'undefined' && gfDrawOn) return;
|
||
openPlaceDossier(e.latlng);
|
||
});
|
||
bindPlaceLongPress(map);
|
||
const pdClose = document.getElementById('pd-close');
|
||
if (pdClose) pdClose.addEventListener('click', closePlaceDossier);
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (ev.key === 'Escape') closePlaceDossier();
|
||
});
|
||
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 follow layer-panel checkboxes (basemap + FIRMS default).
|
||
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;
|
||
conflictsOn = document.getElementById('lp-conflicts-on').checked;
|
||
connectLiveWs();
|
||
loadChokepoints();
|
||
probeConflicts();
|
||
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, on) => {
|
||
const chip = document.getElementById('hud-' + id);
|
||
if (chip) chip.classList.toggle('off', on === false);
|
||
};
|
||
set('fires', firesOn);
|
||
set('cams', camsOn);
|
||
set('blips', blipsOn);
|
||
set('news', 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);
|
||
}
|
||
|
||
/* ── Day/night terminator (sun position, no API) ── */
|
||
let terminatorGroup = null, terminatorOn = false, terminatorTimer = null;
|
||
function subsolarPoint(date) {
|
||
const rad = Math.PI / 180;
|
||
const jd = date.getTime() / 86400000 + 2440587.5;
|
||
const n = jd - 2451545.0;
|
||
const L = (280.460 + 0.9856474 * n) % 360;
|
||
const g = (357.528 + 0.9856003 * n) % 360;
|
||
const lambda = (L + 1.915 * Math.sin(g * rad) + 0.020 * Math.sin(2 * g * rad)) * rad;
|
||
const eps = (23.439 - 0.0000004 * n) * rad;
|
||
const delta = Math.asin(Math.sin(eps) * Math.sin(lambda));
|
||
const alpha = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda));
|
||
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||
let lon = (alpha * 180 / Math.PI) - gmst;
|
||
lon = ((lon + 180) % 360 + 360) % 360 - 180;
|
||
return [delta * 180 / Math.PI, lon];
|
||
}
|
||
function paintTerminator() {
|
||
if (!map || !terminatorOn) return;
|
||
const [lat, lon] = subsolarPoint(new Date());
|
||
const antiLat = -lat;
|
||
const antiLon = lon >= 0 ? lon - 180 : lon + 180;
|
||
const radius = 6378137 * Math.PI / 2;
|
||
const opts = {
|
||
radius, color: '#1e3a5f', weight: 1, fillColor: '#020814',
|
||
fillOpacity: 0.38, interactive: false, pane: 'overlayPane',
|
||
};
|
||
if (terminatorGroup) { map.removeLayer(terminatorGroup); terminatorGroup = null; }
|
||
terminatorGroup = L.layerGroup([
|
||
L.circle([antiLat, antiLon], opts),
|
||
L.circle([antiLat, antiLon + 360], opts),
|
||
L.circle([antiLat, antiLon - 360], opts),
|
||
]).addTo(map);
|
||
const n = document.getElementById('lp-terminator-count');
|
||
if (n) n.textContent = 'on';
|
||
}
|
||
function toggleTerminator() {
|
||
terminatorOn = !!(document.getElementById('lp-terminator-on') && document.getElementById('lp-terminator-on').checked);
|
||
if (terminatorTimer) { clearInterval(terminatorTimer); terminatorTimer = null; }
|
||
if (!terminatorOn) {
|
||
if (terminatorGroup && map) map.removeLayer(terminatorGroup);
|
||
terminatorGroup = null;
|
||
const n = document.getElementById('lp-terminator-count');
|
||
if (n) n.textContent = 'off';
|
||
return;
|
||
}
|
||
paintTerminator();
|
||
terminatorTimer = setInterval(paintTerminator, 60000);
|
||
}
|
||
|
||
/* ── 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; lastFires = []; }
|
||
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;
|
||
if (fenceDvrOn()) 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)]);
|
||
lastFires = Array.isArray(fires) ? fires : [];
|
||
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;
|
||
setLayerCount('lp-fires-count', 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; lastCams = []; }
|
||
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 camThumbsAllowed() {
|
||
return map && map.getZoom() >= CAM_THUMB_MIN_ZOOM;
|
||
}
|
||
function camThumb(c) {
|
||
if (!c.id) return '<div class="thumb placeholder">no preview available</div>';
|
||
if (!camThumbsAllowed()) {
|
||
return '<div class="thumb placeholder">zoom in for preview</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);
|
||
lastCams = [];
|
||
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
|
||
lastCams = Array.isArray(cams) ? cams : [];
|
||
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));
|
||
setLayerCount('lp-cams-count', 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);
|
||
setLayerCount('lp-blips-count', 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;
|
||
setLayerCount('lp-news-count', '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 = '#35e0ff';
|
||
const pins = items.filter(it => Number.isFinite(Number(it.lat)) && Number.isFinite(Number(it.lon)));
|
||
newsGroup = L.layerGroup(pins.map(it => {
|
||
return L.circleMarker([it.lat, it.lon], {
|
||
radius: 6, weight: 1, color: col, fillColor: col, fillOpacity: 0.7,
|
||
renderer: pointCanvas(),
|
||
})
|
||
.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);
|
||
setLayerCount('lp-news-count', 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) {
|
||
setLayerCount(id, '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;
|
||
let badge = '';
|
||
if (src === 'adsb.lol' && acIsEmergency(p)) {
|
||
badge += ' <span class="role-badge emergency">EMERGENCY</span>';
|
||
}
|
||
if (role) {
|
||
badge += ` <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('callsign', p.label);
|
||
add('hex', extra.hex);
|
||
add('registration', extra.reg);
|
||
add('type', extra.type);
|
||
add('aircraft', extra.desc);
|
||
add('operator', extra.ownOp);
|
||
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);
|
||
} 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 feedPulse(feed, p) {
|
||
return feed === 'ac' && acIsEmergency(p);
|
||
}
|
||
function feedIcon(feed, color, heading, pulse) {
|
||
// 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}|${pulse ? 1 : 0}`;
|
||
let ic = feedIconCache.get(key);
|
||
if (!ic) {
|
||
const rot = h >= 0 ? `transform:rotate(${h}deg);` : '';
|
||
const pulseCls = pulse ? ' hdg-emerg' : '';
|
||
ic = L.divIcon({
|
||
className: '',
|
||
html: `<span class="hdg-marker${pulseCls}"><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, feedPulse(feed, p));
|
||
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, feedPulse(feed, p)));
|
||
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);
|
||
sentinelItemId = null;
|
||
sentinelBounds = null;
|
||
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);
|
||
}
|
||
function sentinelStillCovers() {
|
||
if (!map || !sentinelBounds || sentinelBounds.length < 4) return false;
|
||
const c = map.getCenter();
|
||
return c.lng >= sentinelBounds[0] && c.lng <= sentinelBounds[2]
|
||
&& c.lat >= sentinelBounds[1] && c.lat <= sentinelBounds[3];
|
||
}
|
||
async function loadSentinel1() {
|
||
if (!map || !sentinelOn) return;
|
||
if (sentinelLayer && sentinelStillCovers()) 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);
|
||
sentinelItemId = null;
|
||
sentinelBounds = null;
|
||
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;
|
||
}
|
||
if (body.itemId && body.itemId === sentinelItemId && sentinelLayer) {
|
||
if (Array.isArray(body.bbox) && body.bbox.length >= 4) sentinelBounds = body.bbox;
|
||
sentinelLayer.setOpacity(sentinelOpacity);
|
||
if (countEl) countEl.textContent = String(body.polarization || 'SAR').toUpperCase();
|
||
return;
|
||
}
|
||
const attrib = body.attribution || '';
|
||
const layerOpts = {
|
||
opacity: sentinelOpacity,
|
||
maxZoom: 18,
|
||
attribution: attrib,
|
||
noWrap: true,
|
||
};
|
||
if (Array.isArray(body.bbox) && body.bbox.length >= 4) {
|
||
sentinelBounds = body.bbox;
|
||
layerOpts.bounds = L.latLngBounds(
|
||
[body.bbox[1], body.bbox[0]],
|
||
[body.bbox[3], body.bbox[2]],
|
||
);
|
||
} else {
|
||
sentinelBounds = null;
|
||
}
|
||
sentinelLayer = dropLayer(sentinelLayer);
|
||
sentinelLayer = L.tileLayer(body.tileUrl, layerOpts).addTo(map);
|
||
sentinelItemId = body.itemId || null;
|
||
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); lastAlerts = []; }
|
||
}
|
||
async function loadWxAlerts() {
|
||
if (!map) return;
|
||
if (tooZoomedOut()) {
|
||
wxAlertsGroup = dropLayer(wxAlertsGroup);
|
||
lastAlerts = [];
|
||
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 || [];
|
||
lastAlerts = feats;
|
||
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);
|
||
setLayerCount('lp-alerts-count', feats.length.toLocaleString());
|
||
addExtraAttrib('NWS / IEM storm-based warnings');
|
||
} catch (e) {
|
||
if (isAbort(e)) return;
|
||
console.error('Alerts load failed', e);
|
||
setLayerCount('lp-alerts-count', '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); lastAircraft = []; }
|
||
}
|
||
function toggleAircraftMil() {
|
||
acMilOn = document.getElementById('lp-ac-mil-on').checked;
|
||
if (acOn) loadAircraft();
|
||
}
|
||
async function loadAircraft(force) {
|
||
if (!map) return;
|
||
if (!force && fenceDvrOn()) return;
|
||
if (map.getZoom() <= 3) {
|
||
setLayerCount('lp-ac-count', '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;
|
||
const all = Array.isArray(pts) ? pts : [];
|
||
noteMilSupport(all);
|
||
const shown = acMilOn ? all.filter(acVisible) : all;
|
||
lastAircraft = shown;
|
||
acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac');
|
||
setLayerCount('lp-ac-count', (shown.length || 0).toLocaleString());
|
||
const milEl = document.getElementById('lp-ac-mil-count');
|
||
if (milEl) {
|
||
milEl.textContent = acMilOn
|
||
? shown.length.toLocaleString()
|
||
: String(all.filter(q => ((q.extra || {}).role === 'military')).length);
|
||
}
|
||
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>');
|
||
refreshDvrRangeOnce();
|
||
} catch (e) {
|
||
if (isAbort(e)) return;
|
||
console.error('Aircraft load failed', e);
|
||
setLayerCount('lp-ac-count', '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');
|
||
setLayerCount('lp-trains-count', (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);
|
||
setLayerCount('lp-trains-count', 'err');
|
||
}
|
||
}
|
||
async function toggleVessels() {
|
||
vesselsOn = document.getElementById('lp-vessels-on').checked;
|
||
if (vesselsOn) {
|
||
lastVesselSubBox = '';
|
||
await loadVessels();
|
||
} else {
|
||
vesselsGroup = dropLayer(vesselsGroup);
|
||
lastVessels = [];
|
||
clearTimeout(vesselRefollowTimer);
|
||
}
|
||
}
|
||
async function loadVessels(force) {
|
||
if (!map) return;
|
||
if (!force && fenceDvrOn()) return;
|
||
if (map.getZoom() <= 3) {
|
||
setLayerCount('lp-vessels-count', '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;
|
||
lastVessels = Array.isArray(pts) ? pts : [];
|
||
vesselsGroup = renderPoints(vesselsGroup, lastVessels, 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');
|
||
setLayerCount('lp-vessels-count', (pts.length || 0).toLocaleString());
|
||
addExtraAttrib('AISStream');
|
||
refreshDvrRangeOnce();
|
||
// 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);
|
||
setLayerCount('lp-vessels-count', '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';
|
||
}
|
||
}
|
||
|
||
/* ── Place dossier (“What’s here?”) — Nominatim + already-loaded overlays ── */
|
||
const PLACE_PAD_KM = 5;
|
||
function haversineKm(aLat, aLon, bLat, bLon) {
|
||
const toRad = (d) => d * Math.PI / 180;
|
||
const dLat = toRad(bLat - aLat), dLon = toRad(bLon - aLon);
|
||
const a = Math.sin(dLat / 2) ** 2
|
||
+ Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
|
||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||
}
|
||
function placePt(p) {
|
||
const lat = p.lat ?? p.latitude;
|
||
const lon = p.lon ?? p.longitude;
|
||
if (lat == null || lon == null) return null;
|
||
return { lat: Number(lat), lon: Number(lon) };
|
||
}
|
||
function nearbyFromPoints(rows, origin, kind, labelFn) {
|
||
const out = [];
|
||
(rows || []).forEach((p) => {
|
||
const pt = placePt(p);
|
||
if (!pt) return;
|
||
const km = haversineKm(origin.lat, origin.lng, pt.lat, pt.lon);
|
||
if (km <= PLACE_PAD_KM) out.push({ kind, km, label: labelFn(p), lat: pt.lat, lon: pt.lon });
|
||
});
|
||
return out;
|
||
}
|
||
function alertCentroid(f) {
|
||
const g = f && f.geometry;
|
||
if (!g) return null;
|
||
let c = g.coordinates;
|
||
if (g.type === 'Point' && Array.isArray(c)) return { lat: c[1], lon: c[0] };
|
||
while (Array.isArray(c) && Array.isArray(c[0])) c = c[0];
|
||
if (Array.isArray(c) && typeof c[0] === 'number') return { lat: c[1], lon: c[0] };
|
||
return null;
|
||
}
|
||
function nearbyFromAlerts(feats, origin) {
|
||
const out = [];
|
||
(feats || []).forEach((f) => {
|
||
const p = f.properties || {};
|
||
const label = p.event || p.headline || 'Alert';
|
||
const pt = alertCentroid(f);
|
||
if (!pt) return;
|
||
const km = haversineKm(origin.lat, origin.lng, pt.lat, pt.lon);
|
||
if (km <= PLACE_PAD_KM) out.push({ kind: 'alert', km, label, lat: pt.lat, lon: pt.lon });
|
||
});
|
||
return out;
|
||
}
|
||
function collectNearby(origin) {
|
||
const items = [
|
||
...nearbyFromPoints(lastCams, origin, 'camera', (p) => p.location_name || 'Camera'),
|
||
...nearbyFromPoints(lastAircraft, origin, 'aircraft', (p) => p.label || (p.extra || {}).hex || 'Aircraft'),
|
||
...nearbyFromPoints(lastVessels, origin, 'vessel', (p) => p.label || 'Vessel'),
|
||
...nearbyFromPoints(lastFires, origin, 'fire', () => 'FIRMS hotspot'),
|
||
...nearbyFromAlerts(lastAlerts, origin),
|
||
];
|
||
items.sort((a, b) => a.km - b.km);
|
||
return items;
|
||
}
|
||
function renderNearby(origin) {
|
||
const box = document.getElementById('pd-nearby');
|
||
if (!box) return;
|
||
const items = collectNearby(origin);
|
||
if (!items.length) {
|
||
box.innerHTML = '<div class="pd-empty">No loaded cameras, aircraft, vessels, fires, or alerts within 5 km.</div>';
|
||
return;
|
||
}
|
||
const shown = items.slice(0, 24);
|
||
const more = items.length - shown.length;
|
||
box.innerHTML = shown.map((it) => {
|
||
const km = it.km < 1 ? `${Math.round(it.km * 1000)} m` : `${it.km.toFixed(1)} km`;
|
||
return `<button type="button" class="pd-item" data-lat="${it.lat}" data-lon="${it.lon}">`
|
||
+ `<span><span class="k">${esc(it.kind)}</span> ${esc(it.label)}</span>`
|
||
+ `<span class="d">${esc(km)}</span></button>`;
|
||
}).join('') + (more > 0 ? `<div class="pd-empty">+ ${more} more</div>` : '');
|
||
box.querySelectorAll('.pd-item').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const lat = Number(btn.dataset.lat), lon = Number(btn.dataset.lon);
|
||
if (!map || Number.isNaN(lat) || Number.isNaN(lon)) return;
|
||
map.setView([lat, lon], Math.max(map.getZoom(), 10));
|
||
});
|
||
});
|
||
}
|
||
function closePlaceDossier() {
|
||
const panel = document.getElementById('place-dossier');
|
||
if (panel) {
|
||
panel.classList.remove('open');
|
||
panel.setAttribute('aria-hidden', 'true');
|
||
}
|
||
if (placeMarker && map) { map.removeLayer(placeMarker); placeMarker = null; }
|
||
}
|
||
function bindPlaceLongPress(m) {
|
||
const el = m.getContainer();
|
||
const clear = () => {
|
||
if (placeLongPress) { clearTimeout(placeLongPress.timer); placeLongPress = null; }
|
||
};
|
||
el.addEventListener('touchstart', (ev) => {
|
||
if (ev.touches.length !== 1) { clear(); return; }
|
||
const t = ev.touches[0];
|
||
placeLongPress = {
|
||
x: t.clientX, y: t.clientY,
|
||
timer: setTimeout(() => {
|
||
const start = placeLongPress;
|
||
placeLongPress = null;
|
||
if (!start || gfDrawOn) return;
|
||
const latlng = m.mouseEventToLatLng({ clientX: start.x, clientY: start.y });
|
||
openPlaceDossier(latlng);
|
||
}, 550),
|
||
};
|
||
}, { passive: true });
|
||
el.addEventListener('touchmove', (ev) => {
|
||
if (!placeLongPress) return;
|
||
const t = ev.touches[0];
|
||
if (Math.hypot(t.clientX - placeLongPress.x, t.clientY - placeLongPress.y) > 14) clear();
|
||
}, { passive: true });
|
||
el.addEventListener('touchend', clear);
|
||
el.addEventListener('touchcancel', clear);
|
||
el.addEventListener('contextmenu', (ev) => ev.preventDefault());
|
||
}
|
||
async function openPlaceDossier(latlng) {
|
||
if (!latlng || !map) return;
|
||
const panel = document.getElementById('place-dossier');
|
||
const status = document.getElementById('pd-status');
|
||
const coords = document.getElementById('pd-coords');
|
||
const nameEl = document.getElementById('pd-name');
|
||
const addrEl = document.getElementById('pd-addr');
|
||
if (!panel) return;
|
||
panel.classList.add('open');
|
||
panel.setAttribute('aria-hidden', 'false');
|
||
const lat = latlng.lat, lon = latlng.lng;
|
||
if (coords) coords.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
|
||
if (nameEl) nameEl.textContent = '';
|
||
if (addrEl) addrEl.textContent = '';
|
||
if (status) { status.classList.remove('err'); status.textContent = 'Looking up place…'; }
|
||
if (placeMarker) map.removeLayer(placeMarker);
|
||
placeMarker = L.circleMarker([lat, lon], {
|
||
radius: 7, color: '#35e0ff', weight: 2, fillColor: '#35e0ff', fillOpacity: 0.25,
|
||
pane: 'markerPane',
|
||
}).addTo(map);
|
||
renderNearby(latlng);
|
||
const req = ++placeReq;
|
||
try {
|
||
const r = await fetch(`${API}/api/place?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`);
|
||
if (req !== placeReq) return;
|
||
if (!r.ok) throw new Error(`place ${r.status}`);
|
||
const d = await r.json();
|
||
if (req !== placeReq) return;
|
||
if (nameEl) nameEl.textContent = d.name || d.display_name || 'Unknown place';
|
||
if (addrEl) addrEl.textContent = d.display_name && d.name && d.display_name !== d.name
|
||
? d.display_name : '';
|
||
if (status) status.textContent = d.display_name ? 'Nominatim · OSM' : 'No reverse geocode for this point';
|
||
const closeBtn = document.getElementById('pd-close');
|
||
if (closeBtn) closeBtn.focus();
|
||
} catch (e) {
|
||
if (req !== placeReq) return;
|
||
if (status) { status.classList.add('err'); status.textContent = 'Place lookup failed — nearby list is still from loaded overlays.'; }
|
||
}
|
||
}
|
||
|
||
function conflictSeverityColor(sev) {
|
||
const s = String(sev || '').toLowerCase();
|
||
if (s === 'war') return '#ff2a6d';
|
||
if (s === 'high') return '#fb923c';
|
||
if (s === 'elevated') return '#facc15';
|
||
return '#35e0ff';
|
||
}
|
||
function hideConflictsToggle() {
|
||
const row = document.getElementById('conflicts-layer');
|
||
if (row) row.hidden = true;
|
||
const cb = document.getElementById('lp-conflicts-on');
|
||
if (cb) cb.checked = false;
|
||
conflictsOn = false;
|
||
conflictsCache = null;
|
||
conflictsGroup = dropLayer(conflictsGroup);
|
||
}
|
||
function paintConflicts() {
|
||
if (!map || !conflictsOn) return;
|
||
const zones = (conflictsCache && Array.isArray(conflictsCache.zones)) ? conflictsCache.zones : [];
|
||
conflictsGroup = dropLayer(conflictsGroup);
|
||
const markers = [];
|
||
for (const z of zones) {
|
||
if (z.lat == null || z.lon == null) continue;
|
||
const lat = Number(z.lat), lon = Number(z.lon);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||
const col = conflictSeverityColor(z.severity);
|
||
const sev = String(z.severity || '').toLowerCase();
|
||
const radius = sev === 'war' ? 10 : sev === 'high' ? 8 : 7;
|
||
const m = L.circleMarker([lat, lon], {
|
||
radius,
|
||
color: col,
|
||
fillColor: col,
|
||
fillOpacity: 0.28,
|
||
weight: 2,
|
||
className: 'conflict-zone',
|
||
});
|
||
const n = Number(z.eventCount);
|
||
const count = Number.isFinite(n) ? n : 0;
|
||
m.bindPopup(
|
||
`<div class="blip-pop">` +
|
||
`<div class="blip-src">${esc(z.severity || '')} · ${esc(count)} events</div>` +
|
||
`<b>${esc(z.label || '')}</b>` +
|
||
`<div class="blip-desc">${esc(z.description || '')}</div>` +
|
||
`</div>`
|
||
);
|
||
markers.push(m);
|
||
}
|
||
conflictsGroup = L.layerGroup(markers).addTo(map);
|
||
const countEl = document.getElementById('lp-conflicts-count');
|
||
if (countEl) countEl.textContent = markers.length.toLocaleString();
|
||
addExtraAttrib('Curated OSINT conflict catalog');
|
||
}
|
||
async function probeConflicts() {
|
||
await loadConflicts(false);
|
||
}
|
||
async function toggleConflicts() {
|
||
const cb = document.getElementById('lp-conflicts-on');
|
||
conflictsOn = !!(cb && cb.checked);
|
||
if (conflictsOn) await loadConflicts(true);
|
||
else conflictsGroup = dropLayer(conflictsGroup);
|
||
}
|
||
async function loadConflicts(paint) {
|
||
const shouldPaint = paint === true || conflictsOn;
|
||
const req = ++overlayReq.conflicts;
|
||
const countEl = document.getElementById('lp-conflicts-count');
|
||
try {
|
||
if (!conflictsCache) {
|
||
// Own fetch — catalog is viewport-independent; overlayAbort on
|
||
// moveend must not cancel this (and we never refetch on pan).
|
||
const r = await fetch(`${API}/api/conflicts`);
|
||
if (req !== overlayReq.conflicts) return;
|
||
if (r.status === 404) {
|
||
hideConflictsToggle();
|
||
return;
|
||
}
|
||
if (!r.ok) throw new Error('conflicts ' + r.status);
|
||
const body = await r.json();
|
||
if (req !== overlayReq.conflicts) return;
|
||
conflictsCache = body && typeof body === 'object' ? body : { zones: [] };
|
||
}
|
||
const zones = Array.isArray(conflictsCache.zones) ? conflictsCache.zones : [];
|
||
if (countEl) countEl.textContent = zones.length.toLocaleString();
|
||
if (shouldPaint) paintConflicts();
|
||
} catch (e) {
|
||
if (req !== overlayReq.conflicts) return;
|
||
console.error('Conflicts load failed', e);
|
||
if (countEl) countEl.textContent = 'err';
|
||
}
|
||
}
|
||
|
||
/* ═══════════════ INITIAL LOAD ═══════════════ */
|
||
initNav();
|
||
initHudKeys();
|
||
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);
|
||
setTimeout(pollLayerStats, 1800);
|
||
setInterval(() => { if (!statsPollDisabled) pollLayerStats(); }, STATS_POLL_MS);
|
||
setTimeout(pollSwpc, 2200);
|
||
setInterval(pollSwpc, SWPC_POLL_MS);
|
||
|
||
/* Phase 2: DVR slider + geofence draw (no Leaflet.Draw) + fire/aircraft hits */
|
||
function dvrQs() { return dvrTs ? `×tamp=${encodeURIComponent(dvrTs)}` : ''; }
|
||
function fenceDvrOn() { return !!(gfSelectedId && dvrTs && gfAtOk); }
|
||
function refreshDvrRangeOnce() {
|
||
if (dvrRangeFromTracks) return;
|
||
dvrRangeFromTracks = true;
|
||
initDvrRange();
|
||
}
|
||
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 (gfSelectedId) {
|
||
dvrScrubFence().then(ok => {
|
||
if (!ok) {
|
||
if (acOn) loadAircraft(true);
|
||
if (vesselsOn) loadVessels(true);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
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 applyFenceSnapshot(body) {
|
||
const ac = Array.isArray(body && body.aircraft) ? body.aircraft : [];
|
||
const vs = Array.isArray(body && body.vessels) ? body.vessels : [];
|
||
const fires = Array.isArray(body && body.fires) ? body.fires : [];
|
||
if (acOn) {
|
||
lastAircraft = ac;
|
||
acGroup = renderPoints(acGroup, ac, p => acColor(p), true, 'ac');
|
||
setLayerCount('lp-ac-count', ac.length.toLocaleString());
|
||
}
|
||
if (vesselsOn) {
|
||
lastVessels = vs;
|
||
vesselsGroup = renderPoints(vesselsGroup, vs, 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');
|
||
setLayerCount('lp-vessels-count', vs.length.toLocaleString());
|
||
}
|
||
if (typeof firesOn !== 'undefined' && firesOn && map) {
|
||
if (firesHeat) map.removeLayer(firesHeat);
|
||
const pts = fires.map(f => [f.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)])
|
||
.filter(p => p[0] != null && p[1] != null);
|
||
lastFires = fires;
|
||
firesHeat = L.heatLayer(pts, {
|
||
radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2,
|
||
gradient: firesGradient(),
|
||
}).addTo(map);
|
||
if (firesHeat._canvas) firesHeat._canvas.style.opacity = firesOpacity;
|
||
setLayerCount('lp-fires-count', fires.length.toLocaleString());
|
||
}
|
||
}
|
||
async function dvrScrubFence() {
|
||
if (!gfSelectedId || !dvrTs) return false;
|
||
const req = ++gfAtReq;
|
||
try {
|
||
const r = await overlayFetch(
|
||
`${API}/api/geofences/${encodeURIComponent(gfSelectedId)}/at?timestamp=${encodeURIComponent(dvrTs)}`
|
||
);
|
||
if (req !== gfAtReq) return true;
|
||
if (r.status === 404) { gfAtOk = false; return false; }
|
||
if (!r.ok) return false;
|
||
const body = await r.json();
|
||
if (req !== gfAtReq) return true;
|
||
gfAtOk = true;
|
||
applyFenceSnapshot(body);
|
||
return true;
|
||
} catch (e) {
|
||
if (isAbort(e) || req !== gfAtReq) return true;
|
||
return false;
|
||
}
|
||
}
|
||
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 gfHitTime(p) {
|
||
const raw = p && (p.created_at || (p.payload && p.payload.created_at));
|
||
if (!raw) return 'now';
|
||
const d = new Date(raw);
|
||
if (Number.isNaN(d.getTime())) return String(raw).slice(11, 19) || '—';
|
||
return d.toISOString().slice(11, 19) + 'Z';
|
||
}
|
||
function pushGfInbox(p) {
|
||
if (!p) return;
|
||
const box = document.getElementById('gf-inbox');
|
||
if (!box) return;
|
||
const gid = p.geofence_id || '';
|
||
const name = p.geofence_name || gfNameById[gid] || 'Fence';
|
||
const el = document.createElement('button');
|
||
el.type = 'button';
|
||
el.className = 'gf-hit';
|
||
el.dataset.gid = gid;
|
||
if (p.lat != null) el.dataset.lat = String(p.lat);
|
||
if (p.lon != null) el.dataset.lon = String(p.lon);
|
||
el.innerHTML = `<span class="gf-hit-meta">${newsEsc(gfHitTime(p))}</span> · ${newsEsc(name)}` +
|
||
` · ${newsEsc(p.source_kind || '')} · ${newsEsc(p.entity_id || '')}`;
|
||
el.onclick = () => onGfHitClick(el);
|
||
box.prepend(el);
|
||
while (box.children.length > 50) box.removeChild(box.lastChild);
|
||
}
|
||
function onGfHitClick(el) {
|
||
const lat = parseFloat(el.dataset.lat);
|
||
const lon = parseFloat(el.dataset.lon);
|
||
const gid = el.dataset.gid;
|
||
if (map && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||
map.setView([lat, lon], Math.max(map.getZoom() || 0, 10));
|
||
}
|
||
if (gid) selectGeofence(gid, false);
|
||
}
|
||
async function loadGfInbox() {
|
||
try {
|
||
const r = await fetch(`${API}/api/geofence-alerts?limit=50`);
|
||
if (!r.ok) return;
|
||
const rows = await r.json();
|
||
const box = document.getElementById('gf-inbox');
|
||
if (!box) return;
|
||
box.innerHTML = '';
|
||
(Array.isArray(rows) ? rows : []).slice().reverse().forEach(pushGfInbox);
|
||
} catch (e) { /* route may not be on this backend yet */ }
|
||
}
|
||
function syncGfDrawUi() {
|
||
const fin = document.getElementById('gf-finish');
|
||
const can = document.getElementById('gf-cancel');
|
||
if (fin) fin.hidden = !gfDrawOn;
|
||
if (can) can.hidden = !gfDrawOn;
|
||
const btn = document.getElementById('gf-draw');
|
||
if (btn) btn.textContent = gfDrawOn ? 'Drawing…' : 'Draw geofence';
|
||
const bar = document.getElementById('gf-draw-bar');
|
||
if (bar) bar.hidden = !gfDrawOn;
|
||
}
|
||
function cancelGeofenceDraw() {
|
||
if (!gfDrawOn) return;
|
||
toggleGeofenceDraw();
|
||
}
|
||
function toggleGeofenceDraw() {
|
||
gfDrawOn = !gfDrawOn;
|
||
gfVerts = [];
|
||
if (gfLayer && map) { map.removeLayer(gfLayer); gfLayer = null; }
|
||
syncGfDrawUi();
|
||
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;
|
||
if (e) L.DomEvent.stop(e);
|
||
if (gfVerts.length < 3) return;
|
||
const ring = gfVerts.concat([gfVerts[0]]);
|
||
const geojson = { type: 'Polygon', coordinates: [ring] };
|
||
const fallback = 'Fence ' + new Date().toISOString().slice(11, 19);
|
||
let created = null;
|
||
try {
|
||
const r = await fetch(`${API}/api/geofences`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: fallback, geojson }),
|
||
});
|
||
if (r.ok) created = await r.json();
|
||
} catch (err) { console.error('geofence save failed', err); }
|
||
toggleGeofenceDraw();
|
||
if (created && created.id) {
|
||
const typed = window.prompt('Name this watch', created.name || fallback);
|
||
const name = (typed == null ? '' : String(typed).trim()) || (created.name || fallback);
|
||
if (name && name !== created.name) {
|
||
try {
|
||
await fetch(`${API}/api/geofences/${encodeURIComponent(created.id)}`, {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
});
|
||
} catch (err) { console.error('geofence rename failed', err); }
|
||
}
|
||
}
|
||
loadGeofences();
|
||
}
|
||
async function patchGeofence(id, body) {
|
||
if (!id) return;
|
||
try {
|
||
await fetch(`${API}/api/geofences/${encodeURIComponent(id)}`, {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
} catch (err) { console.error('geofence patch failed', err); }
|
||
loadGeofences();
|
||
}
|
||
function patchGfActive(id, on) {
|
||
patchGeofence(id, { active: !!on });
|
||
}
|
||
async function renameGeofence(id) {
|
||
const cur = gfNameById[id] || 'Fence';
|
||
const typed = window.prompt('Rename watch', cur);
|
||
if (typed == null) return;
|
||
const name = String(typed).trim() || cur;
|
||
await patchGeofence(id, { name });
|
||
}
|
||
function goGeofence(id) {
|
||
selectGeofence(id, true);
|
||
}
|
||
function onGfNameClick(id) {
|
||
if (gfSelectedId === id) { deselectGeofence(); return; }
|
||
selectGeofence(id, true);
|
||
}
|
||
function deselectGeofence() {
|
||
gfSelectedId = null;
|
||
document.querySelectorAll('.gf-item.selected').forEach(el => el.classList.remove('selected'));
|
||
if (gfSaved && gfSaved.setStyle) gfSaved.setStyle({ weight: 2, fillOpacity: 0.08 });
|
||
if (dvrTs) {
|
||
if (acOn) loadAircraft();
|
||
if (vesselsOn) loadVessels();
|
||
}
|
||
}
|
||
function selectGeofence(id, fly) {
|
||
if (!id) return;
|
||
gfSelectedId = id;
|
||
gfAtOk = true;
|
||
document.querySelectorAll('.gf-item').forEach(el => {
|
||
el.classList.toggle('selected', el.dataset.id === id);
|
||
});
|
||
const layer = gfLayerById[id];
|
||
if (gfSaved && gfSaved.eachLayer) {
|
||
gfSaved.eachLayer(ly => {
|
||
const fid = ly.feature && ly.feature.properties && ly.feature.properties.id;
|
||
ly.setStyle({
|
||
color: '#ff2e97',
|
||
weight: fid === id ? 4 : 2,
|
||
fillOpacity: fid === id ? 0.18 : 0.08,
|
||
});
|
||
});
|
||
}
|
||
if (layer && map) {
|
||
if (fly !== false && layer.getBounds) {
|
||
try { map.fitBounds(layer.getBounds(), { padding: [28, 28], maxZoom: 12 }); } catch (e) { /* empty */ }
|
||
}
|
||
if (layer.openPopup) layer.openPopup();
|
||
}
|
||
if (fenceDvrOn()) dvrScrubFence();
|
||
}
|
||
async function deleteGeofence(id) {
|
||
if (!id) return;
|
||
if (gfSelectedId === id) gfSelectedId = null;
|
||
try {
|
||
const r = await fetch(`${API}/api/geofences/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||
if (!r.ok && r.status !== 204) throw new Error('delete failed');
|
||
} catch (err) { console.error('geofence delete failed', err); }
|
||
loadGeofences();
|
||
}
|
||
async function loadGeofences() {
|
||
if (!map) return;
|
||
try {
|
||
const r = await overlayFetch(`${API}/api/geofences`);
|
||
const rows = await r.json();
|
||
const list = Array.isArray(rows) ? rows : [];
|
||
gfWatchIds = list.map(f => f.id).filter(Boolean);
|
||
gfNameById = {};
|
||
list.forEach(f => { if (f.id) gfNameById[f.id] = f.name || 'Fence'; });
|
||
if (gfSelectedId && !gfNameById[gfSelectedId]) gfSelectedId = null;
|
||
const box = document.getElementById('gf-list');
|
||
if (box) {
|
||
box.innerHTML = list.map(f => {
|
||
const id = newsEsc(f.id || '');
|
||
const name = newsEsc(f.name || 'Fence');
|
||
const on = f.active === false ? '' : ' checked';
|
||
const sel = f.id === gfSelectedId ? ' selected' : '';
|
||
return `<div class="gf-item${sel}" data-id="${id}">` +
|
||
`<input class="gf-active" type="checkbox"${on} aria-label="Active" ` +
|
||
`onchange="patchGfActive('${id}', this.checked)">` +
|
||
`<button type="button" class="gf-name" title="${name}" onclick="onGfNameClick('${id}')">${name}</button>` +
|
||
`<span class="gf-actions">` +
|
||
`<button class="gf-go" type="button" onclick="goGeofence('${id}')">Go</button>` +
|
||
`<button class="gf-ren" type="button" aria-label="Rename" onclick="renameGeofence('${id}')">Aa</button>` +
|
||
`<button class="gf-del" type="button" onclick="deleteGeofence('${id}')">Delete</button>` +
|
||
`</span></div>`;
|
||
}).join('');
|
||
}
|
||
if (gfSaved && map.hasLayer(gfSaved)) map.removeLayer(gfSaved);
|
||
gfLayerById = {};
|
||
const feats = list.map(f => ({
|
||
type: 'Feature',
|
||
properties: { name: f.name, id: f.id, active: f.active !== false },
|
||
geometry: f.geojson,
|
||
}));
|
||
gfSaved = L.geoJSON({ type: 'FeatureCollection', features: feats }, {
|
||
style: feat => {
|
||
const on = feat.properties && feat.properties.id === gfSelectedId;
|
||
return { color: '#ff2e97', weight: on ? 4 : 2, fillOpacity: on ? 0.18 : 0.08 };
|
||
},
|
||
onEachFeature: (feat, layer) => {
|
||
const id = feat.properties && feat.properties.id;
|
||
const name = newsEsc((feat.properties && feat.properties.name) || 'Fence');
|
||
const active = feat.properties && feat.properties.active;
|
||
if (!id) return;
|
||
gfLayerById[id] = layer;
|
||
const chk = active ? ' checked' : '';
|
||
layer.bindPopup(
|
||
`<div class="gf-pop"><b>${name}</b><br>` +
|
||
`<label><input type="checkbox"${chk} onchange="patchGfActive('${newsEsc(id)}', this.checked)"> active</label><br>` +
|
||
`<button class="gf-del" type="button" onclick="deleteGeofence('${newsEsc(id)}')">Delete</button></div>`
|
||
);
|
||
},
|
||
}).addTo(map);
|
||
sendWatchGeofences();
|
||
} 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();
|
||
loadGfInbox();
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|