Files
intelligence-terminal/dashboard/public/jarvis.html

2408 lines
142 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intelligence Terminal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://d3js.org/topojson.v3.min.js"></script>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script src="https://unpkg.com/globe.gl@2.33.0"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#020408;--panel:rgba(6,14,22,0.82);--glass:rgba(10,20,32,0.55);
--border:rgba(100,240,200,0.12);--border-bright:rgba(100,240,200,0.3);
--text:#e8f4f0;--dim:#6a8a82;--accent:#64f0c8;--accent2:#44ccff;
--warn:#ffb84c;--danger:#ff5f63;--mono:'IBM Plex Mono',monospace;--sans:'Inter',system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);overflow-x:hidden}
.bg-grid{position:fixed;inset:0;pointer-events:none;opacity:0;
background-image:linear-gradient(rgba(100,240,200,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(100,240,200,0.03) 1px,transparent 1px);
background-size:60px 60px;mask-image:radial-gradient(ellipse at 50% 30%,black 20%,transparent 70%)}
.bg-radial{position:fixed;inset:0;pointer-events:none;opacity:0;
background:radial-gradient(ellipse at 50% 0%,rgba(40,120,100,0.15),transparent 50%),radial-gradient(ellipse at 80% 20%,rgba(40,100,180,0.08),transparent 40%)}
/* BOOT */
#boot{position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;align-items:center;justify-content:center;background:var(--bg)}
.logo-ring{width:120px;height:120px;border:2px solid var(--border);border-radius:50%;display:flex;align-items:center;justify-content:center;position:relative;opacity:0}
.logo-ring::before{content:'';position:absolute;inset:-8px;border:1px solid var(--border);border-radius:50%;border-top-color:var(--accent);animation:spin 2s linear infinite}
.logo-ring::after{content:'';position:absolute;inset:-16px;border:1px solid rgba(100,240,200,0.06);border-radius:50%;border-bottom-color:rgba(100,240,200,0.15);animation:spin 3s linear infinite reverse}
@keyframes spin{to{transform:rotate(360deg)}}
.logo-text{font-family:var(--mono);font-size:18px;font-weight:700;letter-spacing:0.2em;color:var(--accent)}
#bootLines{margin-top:32px;font-family:var(--mono);font-size:12px;color:var(--dim);text-align:left;line-height:2;min-width:340px;opacity:0}
#bootLines .ok{color:var(--accent)}
#bootLines .count{color:var(--accent);font-weight:600}
#bootFinal{margin-top:24px;font-family:var(--mono);font-size:14px;font-weight:600;color:var(--accent);letter-spacing:0.15em;opacity:0}
/* MAIN */
#main{opacity:0;min-height:100vh;position:relative;padding:10px 12px}
/* TOPBAR */
.topbar{display:flex;align-items:center;justify-content:space-between;padding:12px 18px;border:1px solid var(--border);background:var(--panel);backdrop-filter:blur(20px);flex-wrap:wrap;gap:10px}
.top-left{display:flex;align-items:center;gap:14px}
.brand{font-family:var(--mono);font-weight:700;font-size:15px;letter-spacing:0.12em;text-transform:uppercase}
.regime-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;font-family:var(--mono);font-size:11px;letter-spacing:0.1em;text-transform:uppercase;border:1px solid rgba(255,95,99,0.3);color:#ffd8d9;background:rgba(255,95,99,0.08)}
.regime-chip .blink{width:6px;height:6px;border-radius:50%;background:var(--danger);box-shadow:0 0 8px var(--danger);animation:pulse-blink 1.5s ease-in-out infinite}
@keyframes pulse-blink{0%,100%{opacity:1}50%{opacity:0.3}}
.top-center{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.region-btn{border:1px solid var(--border);background:rgba(255,255,255,0.02);color:var(--dim);font-family:var(--mono);font-size:11px;padding:6px 12px;letter-spacing:0.08em;text-transform:uppercase;cursor:pointer;transition:all 0.2s}
.region-btn:hover{border-color:var(--accent);color:var(--text)}
.region-btn.active{color:#03140d;background:var(--accent);border-color:var(--accent)}
.top-right{display:flex;align-items:center;gap:10px}
.meta-pill{font-family:var(--mono);font-size:11px;color:var(--dim);letter-spacing:0.06em;padding:5px 10px;border:1px solid var(--border)}
.meta-pill .v{color:var(--text);font-weight:500}
.alert-badge{padding:5px 12px;font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;border:1px solid rgba(255,95,99,0.4);color:#fff;background:linear-gradient(135deg,rgba(255,95,99,0.2),rgba(255,95,99,0.08))}
.guide-btn{padding:5px 12px;font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;border:1px solid rgba(68,204,255,0.28);color:var(--accent2);background:rgba(68,204,255,0.07);cursor:pointer;transition:all 0.2s}
.guide-btn:hover{border-color:rgba(68,204,255,0.5);background:rgba(68,204,255,0.12);color:#d9f7ff}
.perf-pill{cursor:pointer;background:rgba(255,255,255,0.05);transition:all 0.2s}
.perf-pill:hover{border-color:var(--accent2);color:var(--text);background:rgba(68,204,255,0.08)}
/* GRID */
.grid{display:grid;grid-template-columns:240px 1fr 340px;gap:10px;margin-top:10px;min-height:calc(100vh - 100px)}
.col{display:flex;flex-direction:column;gap:10px}
.g-panel{border:1px solid var(--border);background:var(--glass);backdrop-filter:blur(16px);padding:12px;position:relative;overflow:hidden}
.g-panel::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,rgba(100,240,200,0.15),transparent);pointer-events:none}
.sec-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.sec-head h3{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:0.14em;text-transform:uppercase;color:var(--dim)}
.badge{font-family:var(--mono);font-size:10px;padding:2px 7px;border:1px solid var(--border);color:var(--accent)}
/* LEFT RAIL */
.layer-item{display:flex;align-items:center;justify-content:space-between;padding:8px;border:1px solid rgba(255,255,255,0.04);background:rgba(255,255,255,0.02);margin-bottom:4px}
.layer-item{cursor:pointer;transition:border-color .15s ease,background .15s ease,opacity .15s ease}
.layer-item:hover{border-color:rgba(100,240,200,0.24);background:rgba(100,240,200,0.04)}
.layer-item.focused{border-color:rgba(100,240,200,0.6);background:rgba(100,240,200,0.08)}
.layer-item.hidden-layer{opacity:.45;border-color:rgba(255,95,99,0.18)}
.layer-mode{font-family:var(--mono);font-size:8px;color:var(--dim);margin-top:2px;text-transform:uppercase}
.sensor-actions{display:flex;gap:6px;align-items:center}
.mini-btn{border:1px solid rgba(100,240,200,0.18);background:rgba(100,240,200,0.04);color:var(--dim);font-family:var(--mono);font-size:9px;padding:3px 6px;cursor:pointer}
.mini-btn:hover{color:var(--accent);border-color:rgba(100,240,200,0.4)}
2026-05-17 14:30:39 +02:00
.action-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:10px}
.action-btn{border:1px solid rgba(68,204,255,0.24);background:rgba(68,204,255,0.06);color:var(--text);font-family:var(--mono);font-size:9px;padding:7px 6px;cursor:pointer;text-transform:uppercase;letter-spacing:.08em}
.action-btn:hover{border-color:rgba(68,204,255,0.55);color:var(--accent2);background:rgba(68,204,255,0.12)}
.action-btn[disabled]{opacity:.45;cursor:wait}
.terminal-output{min-height:58px;max-height:180px;overflow:auto;border:1px solid rgba(255,255,255,0.05);background:rgba(0,0,0,0.22);padding:8px;font-family:var(--mono);font-size:10px;line-height:1.45;color:var(--dim);white-space:pre-wrap}
.terminal-output strong{color:var(--accent)}
.terminal-output .err{color:var(--danger)}
.layer-left{display:flex;align-items:center;gap:8px}
.ldot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.ldot.air{background:var(--accent);box-shadow:0 0 6px rgba(100,240,200,0.4)}
.ldot.thermal{background:var(--danger);box-shadow:0 0 6px rgba(255,95,99,0.4)}
.ldot.sdr{background:var(--accent2);box-shadow:0 0 6px rgba(68,204,255,0.4)}
.ldot.nuke{background:#ffe082;box-shadow:0 0 6px rgba(255,224,130,0.4)}
.ldot.incident{background:var(--warn);box-shadow:0 0 6px rgba(255,184,76,0.4)}
.ldot.maritime{background:#b388ff;box-shadow:0 0 6px rgba(179,136,255,0.4)}
.ldot.health{background:#69f0ae;box-shadow:0 0 6px rgba(105,240,174,0.4)}
.ldot.news{background:#81d4fa;box-shadow:0 0 6px rgba(129,212,250,0.4)}
.ldot.space{background:#e0b0ff;box-shadow:0 0 6px rgba(224,176,255,0.4)}
.layer-name{font-size:12px;font-weight:500}
.layer-sub{font-size:10px;color:var(--dim)}
.layer-count{font-family:var(--mono);font-size:13px;font-weight:600;color:var(--accent)}
.nuke-ok{padding:8px;border:1px solid rgba(100,240,200,0.15);background:rgba(100,240,200,0.04);font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:0.08em;text-transform:uppercase;margin-bottom:8px}
.site-row{padding:6px 8px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:11px;display:flex;justify-content:space-between}
.site-val{font-family:var(--mono);color:var(--accent);font-size:10px}
.econ-row{display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-size:11px}
.econ-row .elabel{color:var(--dim)}
.econ-row .eval{font-family:var(--mono);font-weight:600}
/* CENTER: MAP */
.map-region-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--border);background:var(--panel);backdrop-filter:blur(20px)}
.map-container{flex:1;min-height:560px;border:1px solid var(--border);background:radial-gradient(ellipse at center,rgba(4,12,20,1),rgba(2,4,8,1));position:relative;overflow:hidden}
#globeViz{width:100%;height:100%;cursor:grab}
#globeViz:active{cursor:grabbing}
#globeViz canvas{outline:none}
#flatMapSvg .land{fill:rgba(180,200,210,0.08);stroke:rgba(200,220,230,0.15);stroke-width:0.5}
#flatMapSvg .land:hover{fill:rgba(100,240,200,0.08)}
#flatMapSvg .graticule{fill:none;stroke:rgba(100,240,200,0.04);stroke-width:0.4}
#flatMapSvg .border{fill:none;stroke:rgba(200,220,230,0.08);stroke-width:0.3}
@keyframes pulse-conflict{0%,100%{opacity:0.5;stroke-width:1.5}50%{opacity:0.9;stroke-width:2.5}}
.conflict-ring{animation:pulse-conflict 2.5s ease-in-out infinite}
@keyframes dash-flow{to{stroke-dashoffset:-20}}
.corridor-flow{animation:dash-flow 2s linear infinite}
.map-legend{position:absolute;bottom:10px;left:12px;display:flex;gap:14px;font-family:var(--mono);font-size:10px;color:var(--dim);letter-spacing:0.06em;text-transform:uppercase;z-index:5;flex-wrap:wrap}
.leg-item{display:flex;align-items:center;gap:5px}
.leg-dot{width:8px;height:8px;border-radius:50%}
.map-hint{position:absolute;top:8px;right:12px;font-family:var(--mono);font-size:9px;color:var(--dim);z-index:5;opacity:0.6;letter-spacing:0.05em}
.map-hint-id{position:absolute;top:8px;right:12px}
.map-controls{position:absolute;top:8px;left:12px;z-index:6;display:flex;flex-direction:column;gap:4px}
.map-ctrl-btn{width:28px;height:28px;border:1px solid var(--border);background:rgba(0,0,0,0.6);color:var(--dim);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(8px);transition:all 0.2s;font-family:var(--mono)}
.map-ctrl-btn:hover{color:var(--accent);border-color:var(--border-bright);background:rgba(100,240,200,0.06)}
.map-ctrl-btn.map-toggle{font-size:14px}
.map-ctrl-btn.off{opacity:0.4}
/* Map popup */
.map-popup{position:absolute;z-index:20;width:280px;padding:12px;background:rgba(6,10,14,0.95);border:1px solid rgba(100,240,200,0.25);backdrop-filter:blur(12px);box-shadow:0 8px 32px rgba(0,0,0,0.5);pointer-events:auto;display:none}
.map-popup.show{display:block}
.map-popup .pp-head{font-family:var(--mono);font-size:10px;font-weight:600;color:var(--accent);letter-spacing:0.1em;text-transform:uppercase;margin-bottom:4px}
.map-popup .pp-text{font-size:11px;line-height:1.4;color:#c8d8d2}
.map-popup .pp-meta{font-family:var(--mono);font-size:10px;color:var(--dim);margin-top:6px}
.map-popup .pp-close{position:absolute;top:6px;right:8px;background:none;border:none;color:var(--dim);font-size:14px;cursor:pointer}
.glossary-overlay{position:fixed;inset:0;z-index:1200;background:rgba(2,6,10,0.72);backdrop-filter:blur(10px);opacity:0;pointer-events:none;transition:opacity 0.25s ease}
.glossary-overlay.show{opacity:1;pointer-events:auto}
.glossary-panel{position:absolute;top:18px;right:18px;width:min(420px,calc(100vw - 32px));max-height:calc(100vh - 36px);display:flex;flex-direction:column;border:1px solid rgba(68,204,255,0.22);background:rgba(5,12,19,0.96);box-shadow:0 18px 48px rgba(0,0,0,0.45)}
.glossary-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px 16px 10px;border-bottom:1px solid rgba(255,255,255,0.06)}
.glossary-kicker{font-family:var(--mono);font-size:10px;letter-spacing:0.12em;text-transform:uppercase;color:var(--accent2);margin-bottom:5px}
.glossary-title{font-size:18px;font-weight:600;line-height:1.15}
.glossary-sub{font-size:11px;line-height:1.45;color:var(--dim);margin-top:6px}
.glossary-close{border:1px solid var(--border);background:rgba(255,255,255,0.03);color:var(--dim);width:30px;height:30px;font-size:18px;cursor:pointer;flex-shrink:0}
.glossary-body{overflow:auto;padding:12px 16px 16px;display:flex;flex-direction:column;gap:10px}
.glossary-card{padding:12px;border:1px solid rgba(255,255,255,0.05);background:rgba(255,255,255,0.02)}
.glossary-term{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:7px}
.glossary-term strong{font-family:var(--mono);font-size:11px;letter-spacing:0.08em;text-transform:uppercase}
.glossary-tag{font-family:var(--mono);font-size:9px;letter-spacing:0.08em;text-transform:uppercase;padding:2px 6px;border:1px solid rgba(100,240,200,0.18);color:var(--accent);background:rgba(100,240,200,0.05)}
.glossary-line{font-size:11px;line-height:1.5;color:#c8d8d2}
.glossary-line + .glossary-line{margin-top:5px}
.glossary-label{font-family:var(--mono);font-size:9px;letter-spacing:0.08em;text-transform:uppercase;color:var(--dim);margin-right:6px}
.glossary-foot{padding:10px 16px 14px;border-top:1px solid rgba(255,255,255,0.06);font-family:var(--mono);font-size:9px;line-height:1.5;color:rgba(106,138,130,0.8)}
.map-loading{position:absolute;inset:0;z-index:12;display:none;align-items:center;justify-content:center;background:linear-gradient(180deg,rgba(2,6,10,0.78),rgba(2,6,10,0.9));backdrop-filter:blur(10px)}
.map-loading.show{display:flex}
.map-loading-card{display:flex;flex-direction:column;align-items:center;gap:10px;padding:16px 18px;border:1px solid rgba(68,204,255,0.18);background:rgba(6,14,22,0.88);box-shadow:0 12px 32px rgba(0,0,0,0.35)}
.map-loading-ring{width:28px;height:28px;border:2px solid rgba(68,204,255,0.16);border-top-color:var(--accent2);border-radius:50%;animation:spin 1s linear infinite}
.map-loading-text{font-family:var(--mono);font-size:10px;letter-spacing:0.12em;text-transform:uppercase;color:var(--accent2)}
/* News label on map */
.news-icon{fill:rgba(129,212,250,0.8);filter:drop-shadow(0 0 3px rgba(129,212,250,0.4));transition:fill .2s}
.news-icon:hover{fill:rgba(129,212,250,1)}
/* LOWER GRID — flex layout for responsive panel sizing */
.lower{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;align-items:flex-start}
.lower .g-panel{min-width:0;box-sizing:border-box}
.lower .lp-ticker{flex:1.2 1 240px;max-width:380px}
.right-delta .delta-list{max-height:200px}
.lower .lp-macro{flex:2.5 1 360px}
.lower .lp-ideas{flex:1.5 1 300px}
.lower-wide{width:100%}
.metrics-row{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:6px}
.mc{padding:10px;border:1px solid rgba(255,255,255,0.05);background:rgba(255,255,255,0.02)}
.mc .ml{font-family:var(--mono);font-size:9px;text-transform:uppercase;letter-spacing:0.08em;color:var(--dim)}
.mc .mv{font-family:var(--mono);font-size:18px;font-weight:600;margin-top:6px;display:block}
.mc .ms{font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;display:block}
.mc .mbar{height:3px;margin-top:8px;background:rgba(255,255,255,0.06);border-radius:1px;overflow:hidden}
.mc .mbar span{display:block;height:100%;border-radius:1px;background:linear-gradient(90deg,rgba(68,204,255,0.4),var(--accent))}
.spark{display:flex;align-items:flex-end;gap:2px;height:24px;margin-top:6px}
.spark-bar{width:6px;background:linear-gradient(to top,rgba(100,240,200,0.3),var(--accent));border-radius:1px 1px 0 0;transition:height 0.3s}
.signal-row{padding:8px 10px;border-left:2px solid rgba(100,240,200,0.2);margin-bottom:4px;background:rgba(255,255,255,0.02)}
.signal-row strong{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;display:block;margin-bottom:2px}
.signal-row p{font-size:11px;line-height:1.35;color:#c8d8d2}
.src-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:4px}
.src-item{display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid rgba(255,255,255,0.04);font-size:11px}
.sd{width:5px;height:5px;border-radius:50%}
.sd.ok{background:var(--accent);box-shadow:0 0 4px rgba(100,240,200,0.4)}
.sd.err{background:var(--danger);box-shadow:0 0 4px rgba(255,95,99,0.4)}
/* RIGHT: OSINT FEED */
.feed{flex:1;overflow-y:auto;max-height:calc(100vh - 160px);padding-right:3px}
.feed::-webkit-scrollbar{width:3px}
.feed::-webkit-scrollbar-thumb{background:rgba(100,240,200,0.2);border-radius:2px}
.ic{padding:10px;border:1px solid rgba(255,255,255,0.05);border-left:2px solid rgba(68,204,255,0.4);background:rgba(255,255,255,0.02);margin-bottom:6px}
.ic.urgent{border-left-color:var(--danger)}
.ic .ic-ch{font-family:var(--mono);font-size:9px;font-weight:600;color:var(--accent);letter-spacing:0.08em;text-transform:uppercase;display:flex;justify-content:space-between;margin-bottom:3px}
.ic .ic-v{color:var(--warn);font-weight:700;padding:1px 5px;border:1px solid rgba(255,184,76,0.3);background:rgba(255,184,76,0.08)}
.ic .ic-t{font-size:11px;line-height:1.4;color:#c8d8d2}
.ic .ic-m{font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;display:flex;gap:6px}
.ic .ic-fl{color:var(--accent2)}
.sm{display:flex;align-items:center;justify-content:space-between;padding:8px;border:1px solid rgba(255,255,255,0.04);margin-bottom:4px}
.sm .sml{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:0.05em}
.sm .smb{flex:1;height:4px;margin:0 10px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden}
.sm .smb span{display:block;height:100%;border-radius:2px;background:linear-gradient(90deg,rgba(68,204,255,0.3),var(--accent))}
.sm .smv{font-family:var(--mono);font-size:14px;font-weight:700;color:var(--accent);min-width:32px;text-align:center;padding:3px 6px;border:1px solid var(--border-bright);background:rgba(100,240,200,0.06)}
/* LEVERAGEABLE IDEAS */
.idea-card{padding:10px;border:1px solid rgba(100,240,200,0.1);background:rgba(100,240,200,0.03);margin-bottom:6px}
.idea-card .idea-type{font-family:var(--mono);font-size:9px;letter-spacing:0.1em;text-transform:uppercase;padding:2px 6px;border:1px solid;display:inline-block;margin-bottom:4px}
.idea-card .idea-type.long{color:var(--accent);border-color:rgba(100,240,200,0.3)}
.idea-card .idea-type.short{color:var(--danger);border-color:rgba(255,95,99,0.3)}
.idea-card .idea-type.hedge{color:var(--warn);border-color:rgba(255,184,76,0.3)}
.idea-card .idea-type.watch{color:var(--accent2);border-color:rgba(68,204,255,0.3)}
.idea-card .idea-type.avoid{color:#b0bec5;border-color:rgba(176,190,197,0.3)}
.idea-card .idea-title{font-size:12px;font-weight:600;margin-bottom:3px}
.idea-card .idea-text{font-size:10px;line-height:1.4;color:var(--dim)}
.idea-card .idea-conf{font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px}
.disclosure{font-family:var(--mono);font-size:8px;color:rgba(106,138,130,0.6);line-height:1.4;padding:6px;border-top:1px solid rgba(255,255,255,0.04);margin-top:6px}
/* NEWS TICKER */
.ticker-wrap{overflow:hidden;max-height:320px;position:relative;border:1px solid rgba(100,240,200,0.08);background:rgba(0,0,0,0.15)}
.ticker-wrap::before,.ticker-wrap::after{content:'';position:absolute;left:0;right:0;height:30px;z-index:2;pointer-events:none}
.ticker-wrap::before{top:0;background:linear-gradient(to bottom,rgba(14,17,22,0.95),transparent)}
.ticker-wrap::after{bottom:0;background:linear-gradient(to top,rgba(14,17,22,0.95),transparent)}
.ticker-track{display:flex;flex-direction:column;animation:tickerScroll var(--ticker-duration,30s) linear infinite;will-change:transform;contain:layout style}
.ticker-wrap:hover .ticker-track{animation-play-state:paused}
@keyframes tickerScroll{0%{transform:translateY(0)}100%{transform:translateY(-50%)}}
.tk-card{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.03);cursor:default;transition:background 0.2s}
.tk-card.clickable{cursor:pointer}
.tk-card .tk-link{display:none;margin-left:auto;font-size:10px;color:var(--dim);transition:color 0.2s}
.tk-card.clickable .tk-link{display:inline-flex;align-items:center}
.tk-card.clickable:hover .tk-link{color:var(--accent)}
.tk-card:hover{background:rgba(100,240,200,0.04)}
.tk-card.urgent{border-left:2px solid var(--danger)}
.tk-src{font-family:var(--mono);font-size:8px;letter-spacing:0.08em;text-transform:uppercase;padding:1px 5px;border:1px solid;display:inline-block;margin-right:4px}
.tk-src.bbc{color:#64b5f6;border-color:rgba(100,181,246,0.3)}
.tk-src.nyt{color:#b0bec5;border-color:rgba(176,190,197,0.3)}
.tk-src.alj{color:#ffd54f;border-color:rgba(255,213,79,0.3)}
.tk-src.gdelt{color:#4dd0e1;border-color:rgba(77,208,225,0.3)}
.tk-src.tg{color:#ffb74d;border-color:rgba(255,183,77,0.3)}
.tk-src.dw{color:#ef9a9a;border-color:rgba(239,154,154,0.3)}
.tk-src.eu{color:#ce93d8;border-color:rgba(206,147,216,0.3)}
.tk-src.af{color:#a5d6a7;border-color:rgba(165,214,167,0.3)}
2026-03-20 06:32:42 -07:00
.tk-src.sa{color:#ffab91;border-color:rgba(255,171,145,0.3)}
.tk-src.ind{color:#ffcc80;border-color:rgba(255,204,128,0.3)}
.tk-src.anz{color:#80cbc4;border-color:rgba(128,203,196,0.3)}
.tk-src.us{color:#90caf9;border-color:rgba(144,202,249,0.3)}
.tk-src.other{color:#b0bec5;border-color:rgba(176,190,197,0.2)}
.tk-head{font-size:11px;line-height:1.35;color:#c8d8d2;margin-top:3px}
.tk-time{font-family:var(--mono);font-size:8px;color:var(--dim);margin-top:2px}
/* DELTA BADGES */
.delta-badge{font-family:var(--mono);font-size:8px;letter-spacing:0.05em;padding:1px 4px;margin-left:4px;border-radius:2px;vertical-align:middle}
.delta-badge.up{color:#81c784;border:1px solid rgba(129,199,132,0.3);background:rgba(129,199,132,0.08)}
.delta-badge.down{color:#ef5350;border:1px solid rgba(239,83,80,0.3);background:rgba(239,83,80,0.08)}
.delta-badge.new{color:#4dd0e1;border:1px solid rgba(77,208,225,0.3);background:rgba(77,208,225,0.08);animation:pulse-new 2s ease infinite}
.delta-list{max-height:160px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(0,229,255,0.2) transparent}
.delta-row{display:flex;align-items:center;gap:6px;padding:3px 0;font-family:var(--mono);font-size:10px;border-bottom:1px solid rgba(255,255,255,0.04)}
.delta-row.new{background:rgba(77,208,225,0.04)}
.delta-label{flex:1;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.delta-val{color:var(--dim);font-size:9px;white-space:nowrap}
@keyframes pulse-new{0%,100%{opacity:0.7}50%{opacity:1}}
/* IDEAS SOURCE BADGE */
.ideas-src{font-family:var(--mono);font-size:8px;letter-spacing:0.08em;padding:2px 6px;border:1px solid;display:inline-block;margin-left:6px}
.ideas-src.llm{color:#ce93d8;border-color:rgba(206,147,216,0.4);background:rgba(206,147,216,0.08)}
.ideas-src.static{color:var(--dim);border-color:rgba(255,255,255,0.1);background:rgba(255,255,255,0.03)}
/* LOW PERFORMANCE MODE */
body.low-perf .bg-grid,body.low-perf .bg-radial{display:none!important}
body.low-perf .topbar,body.low-perf .g-panel,body.low-perf .map-popup,body.low-perf .map-loading{backdrop-filter:none!important}
body.low-perf .logo-ring::before,body.low-perf .logo-ring::after,body.low-perf .regime-chip .blink,body.low-perf .conflict-ring,body.low-perf .corridor-flow{animation:none!important}
body.low-perf .ticker-wrap{overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(100,240,200,0.2) transparent}
body.low-perf .ticker-track{animation:none!important;display:block!important}
body.low-perf .ticker-wrap::before,body.low-perf .ticker-wrap::after{display:none}
body.low-perf .ticker-wrap::-webkit-scrollbar{width:4px}
body.low-perf .ticker-wrap::-webkit-scrollbar-track{background:transparent}
body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,0.2);border-radius:2px}
/* RESPONSIVE */
@media(max-width:1400px){.grid{grid-template-columns:240px 1fr 320px}.metrics-row{grid-template-columns:repeat(3,1fr)}.src-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:1100px){
#main{padding:8px}
.topbar{padding:10px 12px}
.top-left,.top-center,.top-right{width:100%}
.top-center{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
.map-region-bar{display:none}
.top-right{gap:6px ; flex-wrap: wrap; justify-content: center;}
.region-btn,.meta-pill,.alert-badge,.guide-btn{font-size:10px}
.grid{display:flex;flex-direction:column}
#centerCol{order:1}
#rightRail{order:2}
#leftRail{order:3}
.map-container{min-height:420px}
.map-hint{font-size:8px;right:8px}
.map-legend{left:8px;right:8px;bottom:8px;gap:4px}
.leg-item{font-size:8px}
.lower .lp-ticker,.lower .lp-osint,.lower .lp-macro,.lower .lp-ideas{flex:1 1 100%;max-width:none}
.metrics-row{grid-template-columns:repeat(2,1fr)}
.src-grid{grid-template-columns:repeat(2,1fr)}
.glossary-panel{top:auto;right:0;left:0;bottom:0;width:100%;max-height:min(72vh,720px);border-left:none;border-right:none;border-bottom:none}
}
/* CONFLICT LAYER */
@keyframes pulse-conflict{0%,100%{opacity:0.5;stroke-width:1.5}50%{opacity:0.9;stroke-width:2.5}}
.conflict-ring{animation:pulse-conflict 2.5s ease-in-out infinite}
/* FLIGHT CORRIDORS */
.corridor-line{fill:none;opacity:0.6;pointer-events:none}
@keyframes flicker-ghost{0%,100%{opacity:0.15}30%{opacity:0.45}60%{opacity:0.1}85%{opacity:0.35}}
.ghost-marker{animation:flicker-ghost 2s ease-in-out infinite}
@keyframes dash-flow{to{stroke-dashoffset:-20}}
.corridor-flow{animation:dash-flow 2s linear infinite}
/* SPARKLINES */
.spark-svg{display:inline-block;width:52px;height:18px;vertical-align:middle;margin-left:4px}
.spark-line{fill:none;stroke-width:1.5;stroke-linecap:round}
.spark-good{stroke:var(--accent)}
.spark-bad{stroke:var(--danger)}
.spark-dot{r:2}
/* FLAT/GLOBE TOGGLE */
.proj-toggle{position:absolute;top:8px;left:48px;z-index:6;padding:5px 10px;border:1px solid var(--border);background:rgba(0,0,0,0.6);font-family:var(--mono);font-size:9px;cursor:pointer;color:var(--dim);letter-spacing:0.08em;text-transform:uppercase;transition:all 0.2s;backdrop-filter:blur(8px)}
.proj-toggle:hover{border-color:var(--accent);color:var(--accent)}
.proj-toggle.active{color:var(--bg);background:var(--accent);border-color:var(--accent)}
/* GLOBE.GL overrides */
#globeViz .scene-tooltip{font-family:var(--mono)!important;font-size:10px!important;background:rgba(6,10,14,0.9)!important;border:1px solid rgba(100,240,200,0.3)!important;color:var(--text)!important;padding:4px 8px!important;letter-spacing:0.05em}
/* IDEA HORIZON BADGE */
.idea-horizon{font-family:var(--mono);font-size:8px;letter-spacing:0.08em;text-transform:uppercase;padding:1px 5px;border:1px solid rgba(100,240,200,0.15);color:var(--dim);margin-left:6px}
/* APP-STYLE REDESIGN */
:root{
--app-bg:#17191f;
--app-surface:#22252d;
--app-surface-2:#2b2f38;
--app-card:#2a2d35;
--app-card-soft:#242830;
--app-border:#3a3f49;
--app-border-soft:#343942;
--app-text:#f3f5f7;
--app-muted:#aeb6c2;
--app-dim:#77818f;
--app-green:#1bd96a;
--app-green-soft:rgba(27,217,106,0.17);
--app-blue:#5fb4ff;
--app-red:#ff5f6f;
--app-yellow:#ffcc66;
}
body[data-theme="dark"]{
--bg:var(--app-bg);
--panel:var(--app-surface);
--glass:var(--app-card);
--border:var(--app-border);
--border-bright:rgba(27,217,106,0.55);
--text:var(--app-text);
--dim:var(--app-muted);
--accent:var(--app-green);
--accent2:var(--app-blue);
background:var(--app-bg);
}
body[data-theme="light"]{
--bg:#eef1f4;
--panel:#ffffff;
--glass:#ffffff;
--border:#d6dde5;
--border-bright:rgba(18,177,88,0.55);
--text:#161a21;
--dim:#5e6875;
--accent:#11b858;
--accent2:#2677c9;
--app-bg:#eef1f4;
--app-surface:#ffffff;
--app-surface-2:#f5f7fa;
--app-card:#ffffff;
--app-card-soft:#f3f6f9;
--app-border:#d6dde5;
--app-border-soft:#e1e7ee;
--app-text:#161a21;
--app-muted:#5e6875;
--app-dim:#788391;
color:#161a21;
}
body{font-family:var(--sans);letter-spacing:0;background:var(--app-bg)}
.bg-grid,.bg-radial{display:none}
#main.app-root{display:grid;grid-template-columns:112px minmax(0,1fr);gap:18px;min-height:100vh;padding:0;background:var(--app-bg)}
.app-sidebar{position:sticky;top:0;height:100vh;padding:26px 18px 24px;border-right:1px solid rgba(255,255,255,0.05);background:#20232b;display:flex;flex-direction:column;align-items:center;gap:28px}
body[data-theme="light"] .app-sidebar{background:#f8fafc;border-right-color:#dde3ea}
.app-brand-mark{width:54px;height:54px;border:3px solid var(--app-green);border-radius:18px;display:grid;place-items:center;color:var(--app-green);font-weight:800;font-size:19px;letter-spacing:0;background:rgba(27,217,106,0.06)}
.app-brand-mark span{line-height:1}
.app-nav{display:flex;flex-direction:column;gap:12px;width:100%;align-items:center}
.nav-item{width:74px;height:74px;border:0;border-radius:999px;background:transparent;color:var(--app-muted);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;cursor:pointer;transition:background .18s ease,color .18s ease,transform .18s ease}
.nav-item span{width:28px;height:28px;display:grid;place-items:center;font-weight:800;font-size:17px;border:2px solid currentColor;border-radius:10px;line-height:1}
.nav-item small{font-size:9px;font-weight:700;letter-spacing:0;text-transform:none}
.nav-item:hover{background:rgba(255,255,255,0.06);color:var(--app-text);transform:translateY(-1px)}
.nav-item.active{background:rgba(27,217,106,0.22);color:var(--app-green)}
.sidebar-status{margin-top:auto;width:74px;min-height:42px;border-top:1px solid var(--app-border);padding-top:14px;display:flex;align-items:center;justify-content:center;gap:7px;color:var(--app-muted);font-size:11px;font-weight:700}
.sidebar-status-dot{width:8px;height:8px;border-radius:50%;background:var(--app-green);box-shadow:0 0 16px rgba(27,217,106,.5)}
.app-shell{min-width:0;margin:24px 24px 24px 0;border:1px solid var(--app-border);border-radius:30px 0 0 30px;background:#181b21;overflow:hidden;box-shadow:0 24px 70px rgba(0,0,0,.26)}
body[data-theme="light"] .app-shell{background:#eef1f4;box-shadow:0 20px 50px rgba(33,43,54,.12)}
.topbar{border:0;border-bottom:1px solid var(--app-border);border-radius:0;background:var(--app-surface);padding:26px 36px;backdrop-filter:none;display:grid;grid-template-columns:minmax(220px,340px) minmax(240px,1fr);gap:18px;align-items:center}
.top-left,.top-center,.top-right{width:auto}
.top-left{align-items:flex-start;flex-direction:column;gap:6px}
.brand{font-family:var(--sans);font-size:34px;font-weight:800;letter-spacing:0;text-transform:none;color:var(--app-text);line-height:1}
.view-subtitle{font-size:14px;line-height:1.4;color:var(--app-muted);font-weight:600}
.regime-chip{border:0;border-radius:999px;background:var(--app-green-soft);color:var(--app-green);font-family:var(--sans);font-size:12px;letter-spacing:0;text-transform:none;font-weight:800;padding:7px 12px}
.regime-chip .blink{background:var(--app-green);box-shadow:0 0 10px rgba(27,217,106,.6)}
.app-search{height:46px;border:1px solid var(--app-border);border-radius:999px;background:var(--app-card-soft);color:var(--app-dim);display:flex;align-items:center;padding:0 18px;font-size:14px;font-weight:600;min-width:0}
.app-search span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.top-right{grid-column:1/-1;justify-content:flex-start;flex-wrap:wrap}
@media(min-width:1240px){.topbar{grid-template-columns:minmax(260px,1fr) minmax(260px,420px) auto}.top-right{grid-column:auto;justify-content:flex-end}}
.meta-pill,.guide-btn,.alert-badge,.perf-pill{border:1px solid var(--app-border);border-radius:999px;background:var(--app-card-soft);color:var(--app-muted);font-family:var(--sans);font-size:12px;letter-spacing:0;text-transform:none;font-weight:700;padding:8px 12px}
.meta-pill .v{color:var(--app-text)}
.guide-btn{color:var(--app-blue)}
.alert-badge{border-color:rgba(255,95,111,.35);background:rgba(255,95,111,.1);color:#ff9da8}
.theme-switch{display:flex;align-items:center;gap:4px;padding:4px;border:1px solid var(--app-border);border-radius:999px;background:var(--app-card-soft)}
.theme-btn{border:0;border-radius:999px;background:transparent;color:var(--app-muted);font:700 11px var(--sans);padding:7px 10px;cursor:pointer}
.theme-btn.active{background:var(--app-green);color:#06120b}
.grid{display:grid;margin:0;padding:24px;grid-template-columns:300px minmax(0,1fr);gap:16px;min-height:calc(100vh - 122px);background:#181b21}
body[data-theme="light"] .grid{background:#eef1f4}
.col{gap:16px;min-width:0}
#leftRail,#centerCol,#rightRail{order:0}
#rightRail{grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
@media(min-width:1320px){.grid{grid-template-columns:280px minmax(0,1fr) 360px}#rightRail{grid-column:auto;display:flex;flex-direction:column}}
.g-panel,.map-region-bar,.map-container,.glossary-panel{border:1px solid var(--app-border);border-radius:18px;background:var(--app-card);box-shadow:none;backdrop-filter:none}
.g-panel{padding:18px;overflow:hidden}
.g-panel::before{display:none}
.sec-head{margin-bottom:14px;gap:10px}
.sec-head h3{font-family:var(--sans);font-size:17px;font-weight:800;letter-spacing:0;text-transform:none;color:var(--app-text)}
.badge{border:1px solid var(--app-border);border-radius:999px;background:var(--app-card-soft);font-family:var(--sans);font-size:11px;font-weight:800;color:var(--app-green);padding:4px 9px}
.layer-item,.site-row,.econ-row,.src-item,.mc,.signal-row,.sm,.idea-card,.ic,.tk-card{border-color:var(--app-border-soft);border-radius:12px;background:var(--app-card-soft)}
.layer-item{margin-bottom:8px;padding:11px}
.layer-item.focused{border-color:var(--app-green);background:var(--app-green-soft)}
.layer-name,.idea-title{font-weight:800;color:var(--app-text)}
.layer-sub,.layer-mode,.idea-text,.ic .ic-t,.tk-head{color:var(--app-muted)}
.layer-count,.site-val,.eval,.sm .smv{color:var(--app-green)}
.mini-btn,.action-btn,.region-btn,.map-ctrl-btn,.proj-toggle,.glossary-close{border:1px solid var(--app-border);border-radius:10px;background:var(--app-card-soft);font-family:var(--sans);letter-spacing:0;color:var(--app-muted)}
.action-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.action-btn{font-size:12px;font-weight:800;padding:10px 8px}
.action-btn:hover,.mini-btn:hover,.region-btn:hover{border-color:var(--app-green);color:var(--app-green);background:var(--app-green-soft)}
.terminal-output{border:1px solid var(--app-border);border-radius:14px;background:#151820;color:var(--app-muted)}
body[data-theme="light"] .terminal-output{background:#f6f8fb}
.map-region-bar{padding:12px;gap:8px;margin-bottom:0}
.region-btn{font-weight:800;text-transform:none;font-size:12px;padding:8px 12px}
.region-btn.active{background:var(--app-green);color:#06120b;border-color:var(--app-green)}
.map-container{min-height:590px;background:#101319}
body[data-theme="light"] .map-container{background:#e7ecf3}
.map-legend{border:1px solid var(--app-border);border-radius:14px;background:rgba(24,27,33,.86);padding:9px 11px;backdrop-filter:blur(8px)}
.map-hint,.map-hint-id{top:14px;right:18px;color:var(--app-muted);letter-spacing:0;font-family:var(--sans);font-weight:700}
.lower{gap:16px;margin-top:16px}
.lower .lp-ticker{flex:1.2 1 300px;max-width:460px}
.lower .lp-macro{flex:2.4 1 520px}
.lower .lp-ideas{flex:1.4 1 340px}
.lower .source-health{flex:1.5 1 420px}
.metrics-row{gap:8px}
.src-grid{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px}
.feed{max-height:none}
body[data-view="worldview"] .grid{grid-template-columns:300px minmax(0,1fr)}
body[data-view="worldview"] #rightRail,body[data-view="worldview"] .lower{display:none}
body[data-view="worldview"] .map-container{min-height:calc(100vh - 250px)}
body[data-view="sources"] .grid{grid-template-columns:360px minmax(0,1fr)}
body[data-view="sources"] #rightRail,body[data-view="sources"] #mapContainer,body[data-view="sources"] #mapRegionBar{display:none!important}
body[data-view="sources"] .lower .g-panel:not(.source-health){display:none}
body[data-view="sources"] .lower{margin-top:0}
body[data-view="signals"] .grid,body[data-view="ops"] .grid{display:block}
body[data-view="signals"] #leftRail,body[data-view="signals"] #centerCol{display:none}
body[data-view="signals"] #rightRail{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}
body[data-view="signals"] #rightRail .right-actions{display:none}
body[data-view="ops"] #leftRail,body[data-view="ops"] #centerCol{display:none}
body[data-view="ops"] #rightRail{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}
body[data-view="ops"] #rightRail .right-signals,body[data-view="ops"] #rightRail .right-scenarios,body[data-view="ops"] #rightRail .right-delta,body[data-view="ops"] #rightRail .right-osint{display:none}
body[data-view="markets"] .grid{display:block}
body[data-view="markets"] #leftRail,body[data-view="markets"] #rightRail,body[data-view="markets"] #mapContainer,body[data-view="markets"] #mapRegionBar{display:none!important}
body[data-view="markets"] .lower{margin-top:0}
body[data-view="markets"] .lower .g-panel:not(.lp-macro):not(.lp-ideas){display:none}
body[data-view="markets"] .lp-macro,body[data-view="markets"] .lp-ideas{max-width:none}
@media(max-width:760px){
#main.app-root{display:block;padding:0 0 86px}
.app-sidebar{position:fixed;z-index:30;left:0;right:0;bottom:0;top:auto;height:76px;flex-direction:row;padding:8px 12px;border-right:0;border-top:1px solid var(--app-border);gap:12px}
.app-brand-mark,.sidebar-status{display:none}
.app-nav{flex-direction:row;justify-content:space-between;gap:6px;width:100%}
.nav-item{width:54px;height:54px;border-radius:18px}
.nav-item span{width:23px;height:23px;font-size:13px;border-radius:8px}
.nav-item small{display:none}
.app-shell{margin:10px;border-radius:24px;min-height:calc(100vh - 96px)}
.topbar{grid-template-columns:1fr;padding:22px;gap:12px}
.brand{font-size:30px}
.top-center{display:flex;width:100%;overflow:auto}
.top-right{justify-content:flex-start}
.grid{padding:14px;display:flex;flex-direction:column}
body[data-view="worldview"] .grid,body[data-view="sources"] .grid{display:flex}
body[data-view="signals"] #rightRail,body[data-view="ops"] #rightRail{display:flex;flex-direction:column}
.map-container{min-height:420px}
.map-legend{left:8px;right:8px;bottom:8px}
}
/* FINAL APP-SHELL FIDELITY PASS */
body::before{content:'';position:fixed;inset:0;z-index:-1;pointer-events:none;background-image:linear-gradient(180deg,rgba(10,13,18,.72),rgba(10,13,18,.88)),url('./assets/app-shell-texture.png');background-size:auto,420px 420px;background-blend-mode:normal,soft-light;opacity:.5}
#main.app-root{grid-template-columns:150px minmax(0,1fr);gap:0;background:linear-gradient(180deg,#171a20,#11141a)}
.app-sidebar{width:150px;padding:28px 20px 24px;background:linear-gradient(180deg,#151a21,#10151c);border-right:1px solid rgba(124,138,154,.24);box-shadow:inset -1px 0 rgba(255,255,255,.03)}
.rail-live{height:34px;min-width:86px;padding:0 13px;border-radius:999px;background:rgba(255,255,255,.035);display:flex;align-items:center;justify-content:center;gap:8px;color:var(--app-muted);font-weight:800;font-size:12px}
.app-nav{position:relative;gap:18px;margin-top:6px}
.nav-glider{position:absolute;left:50%;top:0;width:86px;height:86px;border-radius:999px;background:radial-gradient(circle at 50% 46%,rgba(27,217,106,.42),rgba(27,217,106,.2) 48%,rgba(27,217,106,0) 72%);box-shadow:0 0 34px rgba(27,217,106,.34),inset 0 0 0 1px rgba(27,217,106,.22);transform:translate(-50%,0);transition:transform .58s cubic-bezier(.2,.9,.2,1),opacity .28s ease;pointer-events:none}
.nav-glider::after{content:'';position:absolute;inset:13px;border-radius:999px;border:2px solid rgba(27,217,106,.8);box-shadow:0 0 22px rgba(27,217,106,.42)}
.nav-item{position:relative;z-index:1;width:96px;height:92px;border-radius:28px;background:transparent;color:#aeb7c4;gap:8px;transition:transform .24s ease,color .24s ease,filter .24s ease}
.nav-item:hover{background:transparent;color:#f3f7fa;transform:translateY(-2px)}
.nav-item.active{background:transparent;color:#fff;filter:drop-shadow(0 12px 28px rgba(27,217,106,.18))}
.nav-icon{width:46px;height:46px;border:0;border-radius:16px;display:grid;place-items:center;background:rgba(255,255,255,.035);box-shadow:inset 0 0 0 2px currentColor;transition:background .24s ease,box-shadow .24s ease,transform .24s ease}
.nav-icon svg{width:26px;height:26px;fill:none;stroke:currentColor;stroke-width:2.05;stroke-linecap:round;stroke-linejoin:round}
.nav-item.active .nav-icon{background:rgba(27,217,106,.18);box-shadow:inset 0 0 0 2px var(--app-green),0 0 0 10px rgba(27,217,106,.04);transform:scale(1.04)}
.nav-item small{font-size:11px;font-weight:800;line-height:1;color:currentColor;text-shadow:0 1px 14px rgba(0,0,0,.35)}
.sidebar-status{width:96px;border-top-color:rgba(124,138,154,.28);font-size:12px}
.app-shell{margin:14px 16px 14px 0;border-radius:32px;background:#181c23;border-color:#35404c;box-shadow:0 24px 80px rgba(0,0,0,.34),inset 0 1px rgba(255,255,255,.035)}
.topbar{padding:30px 34px 18px;grid-template-columns:minmax(280px,420px) minmax(280px,1fr) auto;background:linear-gradient(180deg,#20242c,#1e222a);gap:26px}
.brand{font-size:28px;letter-spacing:-.01em}
.view-subtitle{max-width:420px;color:#97a2b2}
.regime-chip{display:none}
.app-search{height:56px;border-radius:18px;background:#1a1f27;border-color:#38424e;font-size:15px;box-shadow:inset 0 1px rgba(255,255,255,.03)}
.top-right{grid-column:auto;justify-content:flex-end;align-content:flex-start;gap:10px}
.theme-switch,.meta-pill,.guide-btn,.alert-badge,.perf-pill{min-height:43px;border-radius:20px;border-color:#38424e;background:#1d222b;box-shadow:inset 0 1px rgba(255,255,255,.025)}
.theme-switch{padding:5px}
.theme-btn{min-width:52px;padding:8px 12px;font-size:12px}
.theme-btn.active{box-shadow:0 7px 20px rgba(27,217,106,.22)}
.alert-badge{background:rgba(255,95,111,.13);border-color:rgba(255,95,111,.38)}
.grid{padding:20px 24px 24px;grid-template-columns:300px minmax(0,1fr);gap:18px;background:#181c23}
.top-metric-row{grid-column:1/-1;display:grid;grid-template-columns:repeat(6,minmax(130px,1fr));gap:14px}
.top-metric-card{position:relative;overflow:hidden;min-height:86px;padding:16px 18px;border:1px solid #333d49;border-radius:19px;background:linear-gradient(145deg,#252b34,#1d232c);box-shadow:inset 0 1px rgba(255,255,255,.035)}
.top-metric-card::after{content:'';position:absolute;inset:auto -20% -65% -20%;height:80%;background:radial-gradient(circle,rgba(27,217,106,.13),transparent 62%);opacity:0;transition:opacity .3s ease}
.top-metric-card:hover::after{opacity:1}
.tm-icon{width:38px;height:38px;border-radius:14px;display:grid;place-items:center;background:rgba(27,217,106,.11);color:var(--app-green);margin-bottom:8px}
.tm-icon svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}
.tm-label{font-size:10px;font-weight:900;letter-spacing:.06em;text-transform:uppercase;color:#98a4b3}
.tm-value{margin-top:2px;font-size:20px;font-weight:900;color:#f3f6f8;letter-spacing:-.02em}
.tm-sub{font-size:11px;color:#8e99a8}
.g-panel,.map-region-bar,.map-container,.glossary-panel{border-color:#333d49;background:linear-gradient(180deg,#252a33,#202630)}
.g-panel{border-radius:20px;padding:18px;box-shadow:inset 0 1px rgba(255,255,255,.035)}
.map-container{border-radius:22px;background:#0c1118;box-shadow:inset 0 1px rgba(255,255,255,.04),0 18px 48px rgba(0,0,0,.22)}
.map-region-bar{border-radius:20px;padding:16px}
.layer-item,.site-row,.econ-row,.src-item,.mc,.signal-row,.sm,.idea-card,.ic,.tk-card{background:#202630;border-color:#303946;transition:transform .22s ease,border-color .22s ease,background .22s ease,box-shadow .22s ease}
.layer-item:hover,.signal-row:hover,.ic:hover,.tk-card.clickable:hover,.mc:hover{transform:translateY(-2px);border-color:rgba(27,217,106,.32);box-shadow:0 12px 32px rgba(0,0,0,.14)}
.layer-count,.site-val,.eval,.sm .smv{font-weight:900;color:var(--app-green)}
.sec-head h3{font-size:18px;letter-spacing:-.01em}
.badge{background:#1c222b;border-color:#33404c}
.right-actions .terminal-output{min-height:72px}
.sm .smb span,.mc .mbar span{background:linear-gradient(90deg,#1bd96a,#7ee787)}
.regime-chip .blink,.sidebar-status-dot{animation:status-pulse 2.2s ease-in-out infinite}
.map-container::after{content:'';position:absolute;inset:0;border-radius:inherit;pointer-events:none;background:linear-gradient(90deg,transparent,rgba(27,217,106,.035),transparent);transform:translateX(-120%);animation:surface-sweep 8s ease-in-out infinite}
body.view-transitioning .grid{animation:view-shift .42s cubic-bezier(.2,.9,.2,1)}
body.theme-transitioning .app-shell{animation:theme-wash .42s ease}
.motion-stagger{animation:panel-rise .52s cubic-bezier(.2,.9,.2,1) both}
.alert-badge,.delta-badge.new{animation:soft-alert 2.4s ease-in-out infinite}
@keyframes status-pulse{0%,100%{box-shadow:0 0 0 0 rgba(27,217,106,.32)}50%{box-shadow:0 0 0 7px rgba(27,217,106,0)}}
@keyframes surface-sweep{0%,72%{transform:translateX(-120%)}100%{transform:translateX(120%)}}
@keyframes panel-rise{from{opacity:0;transform:translateY(16px) scale(.985);filter:blur(4px)}to{opacity:1;transform:none;filter:none}}
@keyframes view-shift{from{opacity:.18;transform:translateX(18px) scale(.985);filter:blur(8px)}to{opacity:1;transform:none;filter:none}}
@keyframes theme-wash{0%{filter:brightness(.75) saturate(.75)}100%{filter:none}}
@keyframes soft-alert{0%,100%{box-shadow:inset 0 1px rgba(255,255,255,.025)}50%{box-shadow:0 0 0 5px rgba(255,95,111,.05),inset 0 1px rgba(255,255,255,.025)}}
body.low-perf .motion-stagger,body.low-perf .alert-badge,body.low-perf .delta-badge.new,body.low-perf .map-container::after,body.low-perf .regime-chip .blink,body.low-perf .sidebar-status-dot{animation:none!important}
body.low-perf .nav-glider,body.low-perf .nav-item,body.low-perf .layer-item,body.low-perf .signal-row,body.low-perf .ic,body.low-perf .tk-card,body.low-perf .mc{transition:none!important}
@media(min-width:1320px){
.grid{grid-template-columns:300px minmax(0,1fr) 380px}
#rightRail{grid-column:auto}
}
@media(max-width:1240px){
.topbar{grid-template-columns:1fr;gap:14px}
.top-right{justify-content:flex-start}
.top-metric-row{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media(max-width:760px){
body::before{background-size:auto,300px 300px}
#main.app-root{display:block;padding:0 0 88px}
.app-sidebar{width:auto;height:78px;padding:8px 12px}
.rail-live,.sidebar-status{display:none}
.app-nav{margin:0;gap:6px}
.nav-glider{left:0;width:58px;height:58px}
.nav-glider::after{inset:8px}
.nav-item{width:56px;height:58px;border-radius:19px}
.nav-icon{width:30px;height:30px;border-radius:10px}
.nav-icon svg{width:18px;height:18px}
.nav-item small{display:none}
.app-shell{margin:10px;border-radius:24px}
.topbar{padding:22px}
.top-metric-row{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
.top-metric-card{min-height:76px;padding:13px}
}
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
}
</style>
</head>
<body data-theme="dark" data-view="home">
<div id="boot">
<div class="logo-ring"><span class="logo-text">IT</span></div>
<div id="bootLines"></div>
<div id="bootFinal">APP READY</div>
</div>
<div class="bg-radial" id="bgRadial"></div>
<div class="bg-grid" id="bgGrid"></div>
<div id="main" class="app-root">
<aside class="app-sidebar" aria-label="Primary views">
<div class="rail-live"><span class="sidebar-status-dot"></span><span>Live</span></div>
<nav class="app-nav" id="appNav">
<div class="nav-glider" id="navGlider" aria-hidden="true"></div>
<button class="nav-item active" data-view-target="home" onclick="setAppView('home')" title="Home" aria-label="Home"><span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M9 21v-7h6v7"/></svg></span><small>Home</small></button>
<button class="nav-item" data-view-target="worldview" onclick="setAppView('worldview')" title="Worldview" aria-label="Worldview"><span class="nav-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.8 2.6 4.2 5.6 4.2 9S14.8 18.4 12 21"/><path d="M12 3c-2.8 2.6-4.2 5.6-4.2 9S9.2 18.4 12 21"/></svg></span><small>Worldview</small></button>
<button class="nav-item" data-view-target="sources" onclick="setAppView('sources')" title="Sources" aria-label="Sources"><span class="nav-icon"><svg viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="7" ry="3"/><path d="M5 5v6c0 1.7 3.1 3 7 3s7-1.3 7-3V5"/><path d="M5 11v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6"/></svg></span><small>Sources</small></button>
<button class="nav-item" data-view-target="signals" onclick="setAppView('signals')" title="Signals" aria-label="Signals"><span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M3 13h4l3-8 4 14 3-6h4"/><path d="M4 20h16"/></svg></span><small>Signals</small></button>
<button class="nav-item" data-view-target="markets" onclick="setAppView('markets')" title="Markets" aria-label="Markets"><span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 19V5"/><path d="M4 19h17"/><path d="M8 16v-5"/><path d="M13 16V8"/><path d="M18 16v-9"/></svg></span><small>Markets</small></button>
<button class="nav-item" data-view-target="ops" onclick="setAppView('ops')" title="Ops" aria-label="Ops"><span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z"/><path d="m8 9 3 3-3 3"/><path d="M13 15h4"/></svg></span><small>Ops</small></button>
</nav>
<div class="sidebar-status">
<span class="sidebar-status-dot"></span>
<span id="sidebarStatus">Live</span>
</div>
</aside>
<main class="app-shell">
<div class="topbar" id="topbar"></div>
<div class="grid">
<div class="top-metric-row" id="topMetricRow"></div>
<div class="col" id="leftRail"></div>
<div class="col" id="centerCol">
<div class="map-region-bar" id="mapRegionBar"></div>
<div class="map-container" id="mapContainer">
<div id="globeViz"></div>
<svg id="flatMapSvg" style="display:none;width:100%;height:100%;position:absolute;top:0;left:0;cursor:grab"></svg>
<div class="map-loading" id="mapLoading"><div class="map-loading-card"><div class="map-loading-ring"></div><div class="map-loading-text" id="mapLoadingText">Initializing 3D Globe</div></div></div>
<div class="map-legend" id="mapLegend"></div>
<div class="map-hint" id="mapHint">SCROLL TO ZOOM · DRAG TO PAN</div>
<div class="map-controls">
<button class="map-ctrl-btn" onclick="mapZoom(1.5)" title="Zoom in">+</button>
<button class="map-ctrl-btn" onclick="mapZoom(0.67)" title="Zoom out">&minus;</button>
<button class="map-ctrl-btn map-toggle" id="flightToggle" onclick="toggleFlights()" title="Toggle flight routes">&#9992;</button>
</div>
<button class="proj-toggle" id="projToggle" onclick="toggleMapMode()">GLOBE MODE</button>
<div class="map-popup" id="mapPopup"><button class="pp-close" onclick="closePopup()">&times;</button><div class="pp-head"></div><div class="pp-text"></div><div class="pp-meta"></div></div>
</div>
<div class="lower" id="lowerGrid"></div>
</div>
<div class="col" id="rightRail"></div>
</div>
</main>
</div>
<div class="glossary-overlay" id="glossaryOverlay" onclick="if(event.target===this) closeGlossary()">
<div class="glossary-panel">
<div class="glossary-head">
<div>
<div class="glossary-kicker">Signal Guide</div>
<div class="glossary-title">What the signals actually mean</div>
<div class="glossary-sub">Plain-English interpretation, why it matters, and what you should not infer from it.</div>
</div>
<button class="glossary-close" onclick="closeGlossary()" aria-label="Close signal guide">&times;</button>
</div>
<div class="glossary-body" id="glossaryBody"></div>
<div class="glossary-foot">Treat these as interpretation guides, not conclusions. Stronger judgments should come from corroboration across multiple layers, not from a single signal viewed in isolation.</div>
</div>
</div>
<script>
// === DATA ===
function createDashboardShellData(mode = 'offline-shell') {
return {
meta: { version: '2.0.0', timestamp: new Date().toISOString(), totalDurationMs: 0, sourcesQueried: 0, sourcesOk: 0, sourcesFailed: 0, mode },
air: [], thermal: [], tSignals: [], chokepoints: [], nuke: [], nukeSignals: [],
airMeta: { fallback: false, liveTotal: 0, timestamp: null, source: mode },
sdr: { total: 0, online: 0, zones: [] },
tg: { posts: 0, urgent: [], topPosts: [] },
who: [], fred: [], bls: [], defense: [], noaa: { totalAlerts: 0, alerts: [] }, epa: { totalReadings: 0, stations: [] },
energy: { wti: '--', brent: '--', natgas: '--', crudeStocks: null, wtiRecent: [] },
metals: {}, treasury: {}, gscpi: null, acled: { totalEvents: 0, totalFatalities: 0, byRegion: {}, byType: {}, topCountries: {}, deadliestEvents: [] },
gdelt: { totalArticles: 0, conflicts: 0, economy: 0, health: 0, crisis: 0, topTitles: [], geoPoints: [] },
space: { totalNewObjects: 0, militarySats: 0, militaryByCountry: {}, constellations: {}, iss: null, issPosition: null, stationPositions: [], recentLaunches: [], signals: [] },
health: [{ n: 'OFFLINE SHELL', status: 'degraded', err: true, message: 'No embedded operational snapshot. Start the server and fetch /api/data.' }],
news: [], markets: { indexes: [], rates: [], commodities: [], crypto: [], vix: null, timestamp: null }, newsMeta: {},
ideas: [], ideasSource: 'disabled', newsFeed: [], delta: null,
};
}
let D = createDashboardShellData();
// === I18N ===
const L = window.__CRUCIX_LOCALE__ || {};
function t(keyPath, fallback) {
const keys = keyPath.split('.');
let value = L;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
return fallback || keyPath;
}
}
return typeof value === 'string' ? value : (fallback || keyPath);
}
// === GLOBALS ===
let globe = null;
let globeInitialized = false;
let flightsVisible = true;
let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true';
let isFlat = shouldStartFlat();
let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}');
let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons';
let terminalOutput = 'Ready. Live data is loaded from /api/data in server mode.';
let terminalBusy = false;
let currentRegion = 'world';
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
2026-05-17 14:14:45 +02:00
const terminalActionTokenKey = 'crucix_sweep_token';
const appViews = {
home: { title: 'Home', subtitle: 'Sweep summary, alert posture, source status, and the latest operator feed.' },
worldview: { title: 'Worldview', subtitle: 'Globe or flat map with regional focus and layer controls.' },
sources: { title: 'Sources', subtitle: 'Sensor grid, source health, API-key degradation, and data coverage.' },
signals: { title: 'Signals', subtitle: 'Cross-source signals, sweep delta, scenario watchlist, and OSINT context.' },
markets: { title: 'Markets', subtitle: 'Macro indicators, live markets, volatility gauges, and AI-assisted ideas.' },
ops: { title: 'Ops', subtitle: 'Terminal actions, integration state, and low-performance controls.' }
};
let currentView = localStorage.getItem('intelligence_terminal_view') || 'home';
if(!appViews[currentView]) currentView = 'home';
let themePreference = localStorage.getItem('intelligence_terminal_theme') || 'dark';
function resolveTheme(pref){
if(pref === 'auto'){
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
return pref === 'light' ? 'light' : 'dark';
}
function applyTheme(pref = themePreference){
themePreference = pref;
document.body.dataset.theme = resolveTheme(pref);
document.querySelectorAll('.theme-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.themeChoice === pref));
}
function setTheme(pref){
themePreference = pref;
localStorage.setItem('intelligence_terminal_theme', pref);
document.body.classList.add('theme-transitioning');
window.setTimeout(() => document.body.classList.remove('theme-transitioning'), 480);
applyTheme(pref);
renderTopbar();
}
function iconSvg(name){
const icons = {
sweep:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/></svg>',
delta:'<svg viewBox="0 0 24 24"><path d="M4 15l5-5 4 4 7-8"/><path d="M16 6h4v4"/></svg>',
signals:'<svg viewBox="0 0 24 24"><path d="M3 13h4l3-8 4 14 3-6h4"/></svg>',
regions:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.8 2.6 4.2 5.6 4.2 9S14.8 18.4 12 21"/><path d="M12 3c-2.8 2.6-4.2 5.6-4.2 9S9.2 18.4 12 21"/></svg>',
alert:'<svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.7 3 8.3 7 10 4-1.7 7-5.3 7-10V6z"/><path d="M12 8v5"/><path d="M12 16h.01"/></svg>',
status:'<svg viewBox="0 0 24 24"><path d="M12 3 5 6v6c0 4.2 2.8 7.1 7 9 4.2-1.9 7-4.8 7-9V6z"/><path d="m8.5 12 2.2 2.2 4.8-5"/></svg>'
};
return icons[name] || icons.status;
}
function renderTopMetrics(){
const el = document.getElementById('topMetricRow');
if(!el) return;
const delta = D.delta?.summary;
const signalCount = (D.tSignals||[]).length;
const activeRegions = ['world','americas','europe','middleEast','asiaPacific','africa'].length - (D.meta.sourcesQueried === 0 ? 1 : 0);
const highAlert = (D.tg?.urgent?.length || 0) > 0 || (D.noaa?.totalAlerts || 0) > 0 || (D.acled?.totalFatalities || 0) > 0;
const sourceState = D.meta.sourcesQueried ? `${D.meta.sourcesOk}/${D.meta.sourcesQueried}` : '0/0';
const cards = [
{icon:'sweep',label:'Sweep',value:`${(D.meta.totalDurationMs/1000).toFixed(1)}s`,sub:'Last run'},
{icon:'delta',label:'Delta',value:delta?.direction ? delta.direction.replace('-', ' ') : 'Baseline',sub:'Change'},
{icon:'signals',label:'Signals',value:signalCount || (D.tg?.urgent?.length || 0),sub:'New'},
{icon:'regions',label:'Regions',value:activeRegions,sub:'Active'},
{icon:'alert',label:'Alert posture',value:highAlert ? 'High alert' : 'Normal',sub:highAlert ? 'Elevated' : 'Stable',tone:highAlert?'danger':''},
{icon:'status',label:'System status',value:D.meta.sourcesFailed ? 'Degraded' : 'Operational',sub:`Sources ${sourceState}`,tone:D.meta.sourcesFailed?'warn':'ok'}
];
el.innerHTML = cards.map(card => `
<div class="top-metric-card ${card.tone||''}">
<div class="tm-icon">${iconSvg(card.icon)}</div>
<div class="tm-label">${card.label}</div>
<div class="tm-value">${card.value}</div>
<div class="tm-sub">${card.sub}</div>
</div>`).join('');
}
function positionNavGlider(){
const nav = document.getElementById('appNav');
const glider = document.getElementById('navGlider');
const active = nav?.querySelector('.nav-item.active');
if(!nav || !glider || !active) return;
const navRect = nav.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
if(isMobileLayout()){
const x = activeRect.left - navRect.left + (activeRect.width - glider.offsetWidth) / 2;
glider.style.transform = `translate(${x}px,0)`;
} else {
const y = activeRect.top - navRect.top + (activeRect.height - glider.offsetHeight) / 2;
glider.style.transform = `translate(-50%,${y}px)`;
}
}
function shouldAnimateUi(){
return !lowPerfMode && !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
function playPanelEntrances(){
if(!shouldAnimateUi()) return;
const items = [...document.querySelectorAll('.top-metric-card,.g-panel,.map-region-bar,.map-container')];
items.forEach((el,i) => {
el.classList.remove('motion-stagger');
el.style.animationDelay = `${Math.min(i * 36, 420)}ms`;
void el.offsetWidth;
el.classList.add('motion-stagger');
});
}
function renderAppNav(){
document.body.dataset.view = currentView;
document.querySelectorAll('.nav-item[data-view-target]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.viewTarget === currentView);
});
const status = document.getElementById('sidebarStatus');
if(status) status.textContent = currentView === 'home' ? 'Live' : appViews[currentView].title;
requestAnimationFrame(positionNavGlider);
}
function setAppView(view){
if(!appViews[view]) return;
const changed = currentView !== view;
currentView = view;
localStorage.setItem('intelligence_terminal_view', view);
if(changed && shouldAnimateUi()){
document.body.classList.add('view-transitioning');
window.setTimeout(() => document.body.classList.remove('view-transitioning'), 460);
}
renderAppNav();
renderTopbar();
renderTopMetrics();
renderLeftRail();
renderLower();
renderRight();
playPanelEntrances();
refreshMapViewport(true);
}
const layerTypeMap = {
air: ['air'],
thermal: ['thermal'],
sdr: ['sdr'],
maritime: ['maritime'],
nuke: ['nuke', 'radiation'],
conflict: ['conflict'],
health: ['health'],
news: ['news', 'gdelt'],
osint: ['osint'],
space: ['space'],
};
const signalGuideItems = [
{
term:'No Callsign',
category:'Air',
meaning:'OpenSky received an aircraft track without a usable callsign or flight ID in that record.',
matters:'Useful as an opacity signal. A cluster of missing callsigns can indicate incomplete transponder metadata or less transparent traffic.',
notMeaning:'Not proof of military, covert, or hostile activity on its own.',
example:'South China Sea: No Callsign 6 of 152 means 6 tracks in that theater had no usable callsign in the feed.'
},
{
term:'High Altitude',
category:'Air',
meaning:'Aircraft above 12,000 meters, roughly 39,000 feet, in the current OpenSky snapshot.',
matters:'Separates cruise-level traffic from lower-altitude local or regional movement.',
notMeaning:'Not a danger score and not inherently unusual. Commercial jets commonly operate here.',
example:'High Altitude 2 means only 2 tracked aircraft in that hotspot were above the cruise threshold at that snapshot.'
},
{
term:'Top Countries',
category:'Air',
meaning:'The most common OpenSky origin_country values among aircraft in that hotspot.',
matters:'Useful for understanding the rough composition of traffic flowing through a theater.',
notMeaning:'Not who is controlling the aircraft right now and not a direct indicator of military ownership.',
example:'China (61), Philippines (39), Taiwan (17) means those were the top registered origin countries in the snapshot.'
},
{
term:'FRP',
category:'Thermal',
meaning:'Fire Radiative Power. This is the intensity of one specific FIRMS hotspot, measured in megawatts.',
matters:'Higher FRP usually means a hotter, larger, or more energetic fire event at that exact point.',
notMeaning:'Not the intensity of the whole region and not automatic proof of conflict activity.',
example:'Sudan / Horn of Africa: FRP 92.3 MW describes that one hotspot, while Total 1,451 describes the entire regional detection count.'
},
{
term:'Total Detections',
category:'Thermal',
meaning:'The total number of FIRMS thermal detections in the entire region bucket for the current sweep.',
matters:'Useful for spotting unusually active fire clusters, especially when compared with historical baselines or night activity.',
notMeaning:'Not a count for the single map point you clicked and not necessarily a conflict count.',
example:'Total 1,451 means the whole Sudan / Horn of Africa bucket had 1,451 detections in that sweep.'
},
{
term:'Night Detections',
category:'Thermal',
meaning:'Thermal detections tagged as occurring at night inside the broader FIRMS region bucket.',
matters:'Nighttime heat can be more noteworthy because it is less likely to be routine daytime land burning.',
notMeaning:'Not a direct combat indicator. It still needs context from location, baseline, and corroborating sources.',
example:'Night 140 means 140 of the 1,451 regional detections were nighttime detections in that sweep.'
},
{
term:'Chokepoint',
category:'Maritime',
meaning:'A strategic maritime corridor or passage where trade and energy flows can be delayed, diverted, or disrupted.',
matters:'These nodes matter because a disruption here can affect shipping costs, transit times, and commodity pricing globally.',
notMeaning:'Not proof that disruption is happening now. It is a strategic watch location.',
example:'Bab el-Mandeb or the Strait of Hormuz matter because shipping and energy flows concentrate there.'
},
{
term:'SDR Receiver',
category:'Signals',
meaning:'A publicly reachable software-defined radio receiver in or near a region of interest.',
matters:'Dense receiver coverage can give you more ability to monitor communications or signal activity in a theater.',
notMeaning:'Not evidence of hostile emissions or a threat by itself. It is an observation and monitoring layer.',
example:'South China Sea SDR count means publicly accessible KiwiSDR receivers are available in or near that zone.'
},
{
term:'CPM',
category:'Radiation',
meaning:'Counts per minute from a radiation monitoring source, used here for relative radiation status at a site.',
matters:'Useful for spotting anomalies against the site’s normal range or comparing consecutive readings.',
notMeaning:'Not a direct safety verdict on its own. Interpretation depends on local baseline and trend, not the raw number alone.',
example:'A site reading 33 CPM can be normal if that location’s usual background level is in the same range.'
},
{
term:'HY Spread',
category:'Macro',
meaning:'High-yield credit spread, shown here as a stress proxy from FRED credit data.',
matters:'When spreads widen, markets are usually pricing more credit stress and tighter financial conditions.',
notMeaning:'Not a recession call by itself. It is one stress signal among many.',
example:'A rising HY Spread alongside higher VIX and weaker equities is a stronger risk-off pattern than HY alone.'
},
{
term:'VIX',
category:'Macro',
meaning:'The CBOE Volatility Index, commonly used as a market-implied fear or volatility gauge.',
matters:'Higher VIX often means more expected equity volatility and more defensive market positioning.',
notMeaning:'Not a direct forecast of a crash and not a geopolitical indicator by itself.',
example:'VIX above 20 with widening HY spreads is a stronger stress pattern than VIX alone.'
},
{
term:'GSCPI',
category:'Macro',
meaning:'The Global Supply Chain Pressure Index, a broad indicator of global supply-chain strain.',
matters:'It helps translate geopolitical or weather disruptions into likely pressure on shipping, inventory, and pricing.',
notMeaning:'Not a live market price and not a company-specific supply-chain score by itself.',
example:'A higher GSCPI makes route or energy shocks more likely to spill into broader cost pressure.'
},
{
term:'WHO Alert',
category:'Health',
meaning:'A WHO Disease Outbreak News item or outbreak-related bulletin surfaced in the health layer.',
matters:'Useful for watching outbreaks that could affect travel, supply chains, humanitarian stress, or regional operating conditions.',
notMeaning:'Not a pandemic declaration and not automatically high severity.',
example:'A WHO alert in a port-heavy region matters more if it overlaps shipping, border controls, or local instability signals.'
},
{
term:'Sweep Delta',
category:'Platform',
meaning:'The change summary between the current sweep and the previous one, including new, escalated, and de-escalated signals.',
matters:'Useful for spotting what changed recently instead of re-reading the full dashboard from scratch.',
notMeaning:'Not a full risk model. It is a directional change layer on top of the raw signals.',
example:'A delta marked risk-off with several new and escalated items means the latest sweep materially worsened the signal mix.'
}
];
const regionPOV = {
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
world: { lat: 20, lng: 20, altitude: 1.8 },
americas: { lat: 35, lng: -95, altitude: 1.0 },
europe: { lat: 50, lng: 15, altitude: 1.0 },
middleEast: { lat: 28, lng: 45, altitude: 1.1 },
asiaPacific: { lat: 25, lng: 110, altitude: 1.2 },
africa: { lat: 5, lng: 20, altitude: 1.2 }
};
if(lowPerfMode) document.body.classList.add('low-perf');
function isWeakMobileDevice(){
const reducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const memory = navigator.deviceMemory || 0;
const cores = navigator.hardwareConcurrency || 0;
return reducedMotion || (memory > 0 && memory <= 4) || (cores > 0 && cores <= 4);
}
function shouldStartFlat(){
if(!isMobileLayout()) return true;
return lowPerfMode || isWeakMobileDevice();
}
function setMapLoading(show, text='Initializing 3D Globe'){
const overlay = document.getElementById('mapLoading');
const label = document.getElementById('mapLoadingText');
if(!overlay || !label) return;
label.textContent = text;
overlay.classList.toggle('show', show);
}
function togglePerfMode(){
lowPerfMode = !lowPerfMode;
localStorage.setItem('crucix_low_perf', String(lowPerfMode));
document.body.classList.toggle('low-perf', lowPerfMode);
const perfStatus = document.getElementById('perfStatus');
if(perfStatus) perfStatus.textContent = lowPerfMode ? 'LITE' : 'FULL';
if(globe){
globe.controls().autoRotate = !lowPerfMode;
globe.arcDashAnimateTime(lowPerfMode ? 0 : 2000);
}
if(lowPerfMode && isMobileLayout() && !isFlat){
toggleMapMode();
} else {
renderLower();
renderRight();
}
}
// === TOPBAR ===
function getRegionControlsMarkup(){
return ['world','americas','europe','middleEast','asiaPacific','africa'].map(r=>
`<button class="region-btn ${r===currentRegion?'active':''}" data-region="${r}" onclick="setRegion('${r}')">${r==='middleEast'?'MIDDLE EAST':r==='asiaPacific'?'ASIA PACIFIC':r.toUpperCase()}</button>`
).join('');
}
function renderRegionControls(){
const mapRegionBar = document.getElementById('mapRegionBar');
if(!mapRegionBar) return;
if(isMobileLayout()){
mapRegionBar.innerHTML = '';
mapRegionBar.style.display = 'none';
return;
}
mapRegionBar.innerHTML = getRegionControlsMarkup();
mapRegionBar.style.display = 'flex';
}
function renderTopbar(){
const mobile = isMobileLayout();
const ts = new Date(D.meta.timestamp);
const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase();
const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true});
2026-05-17 14:14:45 +02:00
const hasActionToken = !!getTerminalActionToken();
const view = appViews[currentView] || appViews.home;
const direction = D.delta?.summary?.direction;
const deltaLabel = direction === 'risk-off' ? '&#x25B2; '+t('dashboard.riskOff','RISK-OFF') : direction === 'risk-on' ? '&#x25BC; '+t('dashboard.riskOn','RISK-ON') : '&#x25C6; '+t('dashboard.mixed','MIXED');
document.getElementById('topbar').innerHTML=`
<div class="top-left">
<span class="brand">Intelligence Terminal</span>
<span class="view-subtitle">${view.title} / ${view.subtitle}</span>
<span class="regime-chip"><span class="blink"></span>Operator dashboard</span>
</div>
<div class="app-search"><span>Search sources, signals, regions, markets...</span></div>
${mobile ? `<div class="top-center">${getRegionControlsMarkup()}</div>` : ''}
<div class="top-right">
<div class="theme-switch" aria-label="Theme selector">
<button class="theme-btn" data-theme-choice="dark" onclick="setTheme('dark')">Dark</button>
<button class="theme-btn" data-theme-choice="auto" onclick="setTheme('auto')">Auto</button>
<button class="theme-btn" data-theme-choice="light" onclick="setTheme('light')">Light</button>
</div>
<button class="meta-pill perf-pill" onclick="togglePerfMode()" title="Reduce visual effects and start mobile in flat mode">${t('dashboard.visuals','Visuals')} <span class="v" id="perfStatus">${lowPerfMode?t('dashboard.visualsLite','Lite'):t('dashboard.visualsFull','Full')}</span></button>
<span class="meta-pill">${t('dashboard.sweep','SWEEP')} <span class="v">${(D.meta.totalDurationMs/1000).toFixed(1)}s</span></span>
<span class="meta-pill">${d} <span class="v">${timeStr}</span></span>
<span class="meta-pill">${t('dashboard.sources','SOURCES')} <span class="v">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span></span>
${D.delta?.summary ? `<span class="meta-pill">${t('dashboard.delta','DELTA')} <span class="v">${deltaLabel}</span></span>` : ''}
2026-05-17 14:14:45 +02:00
<button class="guide-btn" onclick="configureTerminalActionToken()" title="Configure SWEEP_TOKEN for protected terminal actions">${hasActionToken?'TOKEN SET':'SET TOKEN'}</button>
<button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button>
<span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span>
</div>`;
renderRegionControls();
renderAppNav();
applyTheme(themePreference);
}
2026-05-17 14:14:45 +02:00
function getTerminalActionToken(){
return localStorage.getItem(terminalActionTokenKey) || localStorage.getItem('crucix_terminal_action_token') || '';
}
function configureTerminalActionToken(){
const next = window.prompt('Terminal action token (SWEEP_TOKEN). Leave empty to clear.', getTerminalActionToken());
if(next === null) return;
const clean = next.trim();
if(clean) localStorage.setItem(terminalActionTokenKey, clean);
else localStorage.removeItem(terminalActionTokenKey);
renderTopbar();
}
// === LEFT RAIL ===
function layerMode(key){ return layerModes[key] || 'normal'; }
function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; }
function setLayerMode(key, mode){
if(mode === 'normal') delete layerModes[key]; else layerModes[key] = mode;
localStorage.setItem('crucix_layer_modes', JSON.stringify(layerModes));
renderLeftRail();
if(isFlat && flatG){ flatG.selectAll('*').remove(); drawFlatMap(); }
else plotMarkers();
}
function cycleLayerMode(key, event){
const current = layerMode(key);
const next = event && (event.shiftKey || event.ctrlKey || event.metaKey)
? (current === 'hidden' ? 'normal' : 'hidden')
: (current === 'focus' ? 'normal' : 'focus');
setLayerMode(key, next);
}
function resetLayerModes(){
layerModes = {};
localStorage.removeItem('crucix_layer_modes');
renderLeftRail();
if(isFlat && flatG){ flatG.selectAll('*').remove(); drawFlatMap(); }
else plotMarkers();
}
function toggleSpaceDisplay(){
spaceDisplayMode = spaceDisplayMode === 'icons' ? 'orbits' : 'icons';
localStorage.setItem('crucix_space_display', spaceDisplayMode);
renderLeftRail();
if(isFlat && flatG){ flatG.selectAll('*').remove(); drawFlatMap(); }
else plotMarkers();
}
function shouldShowType(type){
const focused = Object.entries(layerModes).filter(([,mode]) => mode === 'focus').flatMap(([key]) => layerTypeMap[key] || []);
const hidden = Object.entries(layerModes).filter(([,mode]) => mode === 'hidden').flatMap(([key]) => layerTypeMap[key] || []);
if(hidden.includes(type)) return false;
if(focused.length > 0) return focused.includes(type);
return true;
}
function renderLeftRail(){
const totalAir=D.air.reduce((s,a)=>s+a.total,0);
const totalThermal=D.thermal.reduce((s,t)=>s+t.det,0);
const totalNight=D.thermal.reduce((s,t)=>s+t.night,0);
const newsCount=(D.news||[]).length;
const conflictEvents = D.acled?.totalEvents || 0;
const conflictFatal = D.acled?.totalFatalities || 0;
const layers=[
{key:'air',name:t('layers.airActivity','Air Activity'),count:totalAir,dot:'air',sub:`${D.air.length} ${t('layers.theaters','theaters')}`},
{key:'thermal',name:t('layers.thermalSpikes','Thermal Spikes'),count:totalThermal.toLocaleString(),dot:'thermal',sub:`${totalNight.toLocaleString()} ${t('layers.nightDet','night det.')}`},
{key:'sdr',name:t('layers.sdrCoverage','SDR Coverage'),count:D.sdr.total,dot:'sdr',sub:`${D.sdr.online} ${t('layers.online','online')}`},
{key:'maritime',name:t('layers.maritimeWatch','Maritime Watch'),count:D.chokepoints.length,dot:'maritime',sub:t('layers.chokepoints','chokepoints')},
{key:'nuke',name:t('layers.nuclearSites','Nuclear Sites'),count:D.nuke.length,dot:'nuke',sub:t('layers.monitors','monitors')},
{key:'conflict',name:t('layers.conflictEvents','Conflict Events'),count:conflictEvents,dot:'thermal',sub:`${conflictFatal.toLocaleString()} ${t('layers.fatalities','fatalities')}`},
{key:'health',name:t('layers.healthWatch','Health Watch'),count:D.who.length,dot:'health',sub:t('layers.whoAlerts','WHO alerts')},
{key:'news',name:t('layers.worldNews','World News'),count:newsCount,dot:'news',sub:t('layers.rssGeolocated','RSS geolocated')},
{key:'osint',name:t('layers.osintFeed','OSINT Feed'),count:D.tg.posts,dot:'incident',sub:`${D.tg.urgent.length} ${t('badges.urgent','urgent').toLowerCase()}`},
{key:'space',name:t('layers.spaceActivity','Satellites'),count:D.space?.militarySats||0,dot:'space',sub:`${D.space?.totalNewObjects||0} ${t('space.newLast30d','new (30d)')}`}
];
const allNormal=D.nuke.every(s=>!s.anom);
const nukeHtml=D.nuke.map(s=>`<div class="site-row"><span>${s.site}</span><span class="site-val">${s.n>0?(s.cpm?.toFixed(1)||'--')+' CPM':'No data'}</span></div>`).join('');
const vix=D.fred.find(f=>f.id==='VIXCLS');
const hy=D.fred.find(f=>f.id==='BAMLH0A0HYM2');
const usd=D.fred.find(f=>f.id==='DTWEXBGS');
const m2=D.fred.find(f=>f.id==='M2SL');
const mort=D.fred.find(f=>f.id==='MORTGAGE30US');
const claims=D.fred.find(f=>f.id==='ICSA');
document.getElementById('leftRail').innerHTML=`
<div class="g-panel source-layers">
<div class="sec-head"><h3>${t('panels.sensorGrid','Sensor Grid')}</h3><div class="sensor-actions"><button class="mini-btn" onclick="resetLayerModes();event.stopPropagation()">RESET</button><span class="badge">${t('badges.live','LIVE')}</span></div></div>
${layers.map(l=>`<div class="layer-item ${layerMode(l.key)==='focus'?'focused':''} ${layerMode(l.key)==='hidden'?'hidden-layer':''}" onclick="cycleLayerMode('${l.key}',event)" title="Click to focus. Shift/Ctrl-click to hide."><div class="layer-left"><div class="ldot ${l.dot}"></div><div><div class="layer-name">${l.name}</div><div class="layer-sub">${l.sub}</div><div class="layer-mode">${layerModeLabel(l.key)}</div></div></div><div class="layer-count">${l.count}</div></div>`).join('')}
</div>
<div class="g-panel nuclear-panel">
<div class="sec-head"><h3>${t('panels.nuclearWatch','Nuclear Watch')}</h3><span class="badge">${t('badges.radiation','RADIATION')}</span></div>
<div class="nuke-ok">${allNormal?'&#9679; '+t('nuclear.allSitesNormal','ALL SITES NORMAL'):'&#9888; '+t('nuclear.anomalyDetected','ANOMALY DETECTED')}</div>
${nukeHtml}
</div>
<div class="g-panel risk-panel">
<div class="sec-head"><h3>${t('panels.riskGauges','Risk Gauges')}</h3><span class="badge">${t('badges.stress','STRESS')}</span></div>
<div class="econ-row"><span class="elabel">${t('metrics.vix','VIX')} (Fear)</span><span class="eval" style="color:${vix?.value>20?'var(--warn)':'var(--accent)'}">${vix?.value||'--'}</span></div>
<div class="econ-row"><span class="elabel">${t('metrics.hySpread','HY Spread')}</span><span class="eval">${hy?.value||'--'}</span></div>
<div class="econ-row"><span class="elabel">${t('metrics.usdIndex','USD Index')}</span><span class="eval">${usd?.value?.toFixed(1)||'--'}</span></div>
<div class="econ-row"><span class="elabel">${t('metrics.joblessClaims','Jobless Claims')}</span><span class="eval">${claims?.value?.toLocaleString()||'--'}</span></div>
<div class="econ-row"><span class="elabel">${t('metrics.mortgage30y','30Y Mortgage')}</span><span class="eval">${mort?.value||'--'}%</span></div>
<div class="econ-row"><span class="elabel">${t('metrics.m2Supply','M2 Supply')}</span><span class="eval">$${(m2?.value/1000)?.toFixed(1)||'--'}T</span></div>
<div class="econ-row"><span class="elabel">${t('metrics.natDebt','Nat. Debt')}</span><span class="eval">$${(parseFloat(D.treasury.totalDebt)/1e12).toFixed(2)}T</span></div>
</div>
<div class="g-panel space-panel">
<div class="sec-head"><h3>${t('panels.spaceWatch','Space Watch')}</h3><button class="mini-btn" onclick="toggleSpaceDisplay()">${spaceDisplayMode.toUpperCase()}</button></div>
${D.space ? `
<div class="econ-row"><span class="elabel">New Objects (30d)</span><span class="eval" style="color:var(--accent2)">${D.space.totalNewObjects||0}</span></div>
<div class="econ-row"><span class="elabel">Military Sats</span><span class="eval">${D.space.militarySats||0}</span></div>
<div class="econ-row"><span class="elabel">Starlink</span><span class="eval">${D.space.constellations?.starlink||0}</span></div>
<div class="econ-row"><span class="elabel">OneWeb</span><span class="eval">${D.space.constellations?.oneweb||0}</span></div>
${D.space.iss ? `<div class="econ-row"><span class="elabel">ISS</span><span class="eval" style="color:var(--accent)">ALT ${((D.space.iss.apogee+D.space.iss.perigee)/2).toFixed(0)} km</span></div>` : ''}
${Object.entries(D.space.militaryByCountry||{}).sort((a,b)=>b[1]-a[1]).slice(0,4).map(([c,n])=>`<div class="econ-row"><span class="elabel" style="padding-left:8px">${c}</span><span class="eval" style="font-size:10px">${n} mil sats</span></div>`).join('')}
${(D.space.signals||[]).length ? `<div style="margin-top:6px;padding:6px 8px;border:1px solid rgba(68,204,255,0.2);background:rgba(68,204,255,0.04);font-family:var(--mono);font-size:9px;color:var(--accent2);line-height:1.5">${D.space.signals.slice(0,2).join('<br>')}</div>` : ''}
` : '<div style="font-family:var(--mono);font-size:10px;color:var(--dim)">NO SPACE DATA</div>'}
</div>`;
}
// === MAP ===
let mapLifecycleBound = false;
function bindMapLifecycleEvents(){
if(mapLifecycleBound) return;
mapLifecycleBound = true;
window.addEventListener('resize', () => syncResponsiveLayout());
window.addEventListener('orientationchange', () => setTimeout(() => syncResponsiveLayout(true), 150));
document.addEventListener('visibilitychange', () => {
if(!document.hidden) setTimeout(() => syncResponsiveLayout(true), 150);
});
window.addEventListener('pageshow', () => setTimeout(() => syncResponsiveLayout(true), 150));
}
function renderMapLegend(){
document.getElementById('mapLegend').innerHTML=
[{c:'#64f0c8',l:t('map.airTraffic','Air Traffic')},{c:'#ff5f63',l:t('map.thermalFire','Thermal/Fire')},{c:'rgba(255,120,80,0.8)',l:t('map.conflict','Conflict')},{c:'#44ccff',l:t('map.sdrReceiver','SDR Receiver')},
{c:'#ffe082',l:t('map.nuclearSite','Nuclear Site')},{c:'#b388ff',l:t('map.chokepoint','Chokepoint')},{c:'#ffb84c',l:t('map.osintEvent','OSINT Event')},{c:'#69f0ae',l:t('map.healthAlert','Health Alert')},{c:'#81d4fa',l:t('map.worldNews','World News')},{c:'#ff9800',l:t('map.weatherAlert','Weather Alert')},{c:'#cddc39',l:t('map.epaRadNet','EPA RadNet')},{c:'#ffffff',l:t('map.spaceStation','Space Station')},{c:'#6495ed',l:t('map.gdeltEvent','GDELT Event')}]
.map(x=>`<div class="leg-item"><div class="leg-dot" style="background:${x.c}"></div>${x.l}</div>`).join('');
}
function initMap(){
bindMapLifecycleEvents();
renderMapLegend();
if(isFlat){
if(globe && typeof globe.pauseAnimation === 'function') globe.pauseAnimation();
document.getElementById('globeViz').style.display = 'none';
document.getElementById('flatMapSvg').style.display = 'block';
document.getElementById('projToggle').textContent = 'GLOBE MODE';
document.getElementById('mapHint').textContent = 'SCROLL TO ZOOM · DRAG TO PAN';
if(!flatSvg) initFlatMap();
else { flatG.selectAll('*').remove(); drawFlatMap(); }
setMapLoading(false);
return;
}
setMapLoading(true, 'Initializing 3D Globe');
requestAnimationFrame(() => {
try {
initGlobe();
setMapLoading(false);
} catch {
isFlat = true;
document.getElementById('globeViz').style.display = 'none';
document.getElementById('flatMapSvg').style.display = 'block';
document.getElementById('projToggle').textContent = 'GLOBE MODE';
document.getElementById('mapHint').textContent = '3D LOAD FAILED · FLAT MODE';
if(!flatSvg) initFlatMap();
else { flatG.selectAll('*').remove(); drawFlatMap(); }
setMapLoading(false);
}
});
}
function initGlobe(){
if(globeInitialized && globe){
if(typeof globe.resumeAnimation === 'function') globe.resumeAnimation();
document.getElementById('globeViz').style.display = 'block';
document.getElementById('flatMapSvg').style.display = 'none';
document.getElementById('projToggle').textContent = 'FLAT MODE';
document.getElementById('mapHint').textContent = 'DRAG TO ROTATE · SCROLL TO ZOOM';
return;
}
const container = document.getElementById('mapContainer');
const w = container.clientWidth;
const h = container.clientHeight || 560;
globe = Globe()
.width(w)
.height(h)
.globeImageUrl('//unpkg.com/three-globe@2.33.0/example/img/earth-night.jpg')
.bumpImageUrl('//unpkg.com/three-globe@2.33.0/example/img/earth-topology.png')
.backgroundImageUrl('')
.backgroundColor('rgba(0,0,0,0)')
.atmosphereColor('#64f0c8')
.atmosphereAltitude(0.18)
.showGraticules(true)
// Points layer (main markers)
.pointAltitude(d => d.alt || 0.01)
.pointRadius(d => d.size || 0.3)
.pointColor(d => d.color)
.pointLabel(d => `<b>${d.popHead||''}</b><br><span style="opacity:0.7">${d.popMeta||''}</span>`)
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
.onPointClick((pt, ev) => { showPopup(ev, pt.popHead, pt.popText, pt.popMeta, pt.lat, pt.lng, pt.alt); })
.onPointHover(pt => { document.getElementById('globeViz').style.cursor = pt ? 'pointer' : 'grab'; })
// Arcs layer (flight corridors)
.arcColor(d => d.color)
.arcStroke(d => d.stroke || 0.4)
.arcDashLength(0.4)
.arcDashGap(0.2)
.arcDashAnimateTime(2000)
.arcAltitudeAutoScale(0.3)
.arcLabel(d => d.label || '')
// Rings layer (pulsing conflict events)
.ringColor(d => t => `rgba(255,120,80,${1-t})`)
.ringMaxRadius(d => d.maxR || 3)
.ringPropagationSpeed(d => d.speed || 2)
.ringRepeatPeriod(d => d.period || 800)
// Labels layer
.labelText(d => d.text)
.labelSize(d => d.size || 0.4)
.labelColor(d => d.color || 'rgba(106,138,130,0.9)')
.labelDotRadius(0)
.labelAltitude(0.012)
.labelResolution(2)
(document.getElementById('globeViz'));
// Style the WebGL scene
const scene = globe.scene();
const renderer = globe.renderer();
renderer.setClearColor(0x000000, 0);
// Add subtle stars background
const starGeom = new THREE.BufferGeometry();
const starVerts = [];
for(let i=0; i<2000; i++){
const r = 800 + Math.random()*200;
const theta = Math.random()*Math.PI*2;
const phi = Math.acos(2*Math.random()-1);
starVerts.push(r*Math.sin(phi)*Math.cos(theta), r*Math.sin(phi)*Math.sin(theta), r*Math.cos(phi));
}
starGeom.setAttribute('position', new THREE.Float32BufferAttribute(starVerts, 3));
const starMat = new THREE.PointsMaterial({color:0x88bbaa, size:0.8, transparent:true, opacity:0.6});
scene.add(new THREE.Points(starGeom, starMat));
// Customize graticule color
scene.traverse(obj => {
if(obj.material && obj.type === 'Line'){
obj.material.color.set(0x1a3a2a);
obj.material.opacity = 0.3;
obj.material.transparent = true;
}
});
// Set initial POV
globe.pointOfView(regionPOV.world, 0);
// Auto-rotate slowly
globe.controls().autoRotate = !lowPerfMode;
globe.controls().autoRotateSpeed = 0.3;
globe.controls().enableDamping = true;
globe.controls().dampingFactor = 0.1;
// Stop auto-rotate on interaction, resume after 10s
let rotateTimeout;
const el = document.getElementById('globeViz');
el.addEventListener('mousedown', () => {
globe.controls().autoRotate = false;
clearTimeout(rotateTimeout);
});
el.addEventListener('mouseup', () => {
rotateTimeout = setTimeout(() => { if(globe && !lowPerfMode) globe.controls().autoRotate = true; }, 10000);
});
// Plot globe markers (preloaded but hidden)
plotMarkers();
// Start in flat mode — hide globe, show flat map
if(isFlat){
document.getElementById('globeViz').style.display = 'none';
document.getElementById('flatMapSvg').style.display = 'block';
initFlatMap();
} else {
document.getElementById('globeViz').style.display = 'block';
document.getElementById('flatMapSvg').style.display = 'none';
document.getElementById('projToggle').textContent = 'FLAT MODE';
document.getElementById('mapHint').textContent = 'DRAG TO ROTATE · SCROLL TO ZOOM';
}
globeInitialized = true;
}
function plotMarkers(){
if(!globe) return;
const points = [];
const labels = [];
// === Air hotspots (green) ===
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
if(flightsVisible) D.air.forEach((a,i)=>{
const c=airCoords[i]; if(!c) return;
points.push({
lat:c.lat, lng:c.lon, size:0.25+a.total/200, alt:0.015,
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
color:'rgba(100,240,200,0.8)', type:'air', priority:1,
label: a.region.replace(' Region','')+' '+a.total,
popHead: a.region, popMeta: 'Air Activity',
popText: `${a.total} aircraft tracked<br>No callsign: ${a.noCallsign}<br>High altitude: ${a.highAlt}<br>Top: ${a.top.slice(0,3).map(t=>t[0]+' ('+t[1]+')').join(', ')}`
});
labels.push({lat:c.lat, lng:c.lon+2, text:a.region.replace(' Region','')+' '+a.total, size:0.35, color:'rgba(106,138,130,0.8)'});
});
// === Thermal/fire (red) ===
D.thermal.forEach(t=>{
t.fires.forEach(f=>{
points.push({
lat:f.lat, lng:f.lon, size:0.12+Math.min(f.frp/200,0.3), alt:0.008,
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
color:'rgba(255,95,99,0.7)', type:'thermal', priority:3,
popHead:'Thermal Detection', popMeta:'FIRMS Satellite',
popText:`Region: ${t.region}<br>FRP: ${f.frp.toFixed(1)} MW<br>Total: ${t.det.toLocaleString()}<br>Night: ${t.night.toLocaleString()}`
});
});
});
// === Maritime chokepoints (purple) ===
D.chokepoints.forEach(cp=>{
points.push({
lat:cp.lat, lng:cp.lon, size:0.35, alt:0.02,
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
color:'rgba(179,136,255,0.8)', type:'maritime', priority:1,
popHead:cp.label, popMeta:'Maritime Intelligence', popText:cp.note
});
labels.push({lat:cp.lat, lng:cp.lon+1.5, text:cp.label, size:0.3, color:'rgba(179,136,255,0.6)'});
});
// === Nuclear sites (yellow) ===
const nukeCoords=[{lat:47.5,lon:34.6},{lat:51.4,lon:30.1},{lat:28.8,lon:50.9},{lat:39.8,lon:125.8},{lat:37.4,lon:141},{lat:31.0,lon:35.1}];
D.nuke.forEach((n,i)=>{
const c=nukeCoords[i]; if(!c) return;
points.push({
lat:c.lat, lng:c.lon, size:0.3, alt:0.012,
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
color: n.anom ? 'rgba(255,95,99,0.9)' : 'rgba(255,224,130,0.8)', type:'nuke', priority:2,
popHead:n.site, popMeta:'Radiation Monitoring',
popText:`Status: ${n.anom?'ANOMALY':'Normal'}<br>Avg CPM: ${n.cpm?.toFixed(1)||'No data'}<br>Readings: ${n.n}`
});
});
// === SDR receivers (cyan) ===
D.sdr.zones.forEach(z=>{
z.receivers.forEach(r=>{
points.push({
lat:r.lat, lng:r.lon, size:0.15, alt:0.005,
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
color:'rgba(68,204,255,0.6)', type:'sdr', priority:3,
popHead:'SDR Receiver', popMeta:'KiwiSDR Network',
popText:`${r.name}<br>Zone: ${z.region}<br>${z.count} in zone`
});
});
});
// === OSINT events from Telegram (orange) ===
const osintGeo=[{lat:45,lon:41,idx:0},{lat:48,lon:37,idx:1},{lat:48.5,lon:37.5,idx:2},{lat:45,lon:40.2,idx:3},{lat:50.6,lon:36.6,idx:5},{lat:48.5,lon:35,idx:6}];
osintGeo.forEach(o=>{
const post=D.tg.urgent[o.idx]; if(!post) return;
points.push({
lat:o.lat, lng:o.lon, size:0.3, alt:0.018,
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
color:'rgba(255,184,76,0.8)', type:'osint', priority:2,
popHead:(post.channel||'').toUpperCase(), popMeta:`${post.views?.toLocaleString()||'?'} views`,
popText:cleanText(post.text?.substring(0,200)||'')
});
});
// === WHO health alerts (green) ===
const whoGeo=[{lat:0.3,lon:32.6},{lat:-6.2,lon:106.8},{lat:-4.3,lon:15.3},{lat:35,lon:105},{lat:12.5,lon:105},{lat:35,lon:105},{lat:28,lon:84},{lat:24,lon:45},{lat:30,lon:70},{lat:-0.8,lon:11.6}];
D.who.slice(0,10).forEach((w,i)=>{
const c=whoGeo[i]; if(!c) return;
points.push({
lat:c.lat, lng:c.lon, size:0.25, alt:0.01,
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
color:'rgba(105,240,174,0.7)', type:'health', priority:2,
popHead:w.title, popMeta:'WHO Outbreak', popText:w.summary||''
});
});
// === News markers (light blue) ===
(D.news||[]).forEach(n=>{
points.push({
lat:n.lat, lng:n.lon, size:0.2, alt:0.008,
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
color:'rgba(129,212,250,0.7)', type:'news', priority:3,
popHead:n.source+' NEWS', popMeta:n.region+' · '+getAge(n.date),
popText:cleanText(n.title)
});
});
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
// === NOAA severe weather alerts (orange) ===
(D.noaa?.alerts||[]).forEach(a=>{
points.push({
lat:a.lat, lng:a.lon, size:0.22, alt:0.01,
color:'rgba(255,152,0,0.8)', type:'weather', priority:2,
popHead:a.event, popMeta:'NOAA/NWS · '+a.severity,
popText:a.headline||''
});
});
// === EPA RadNet stations (lime green) ===
(D.epa?.stations||[]).forEach(s=>{
points.push({
lat:s.lat, lng:s.lon, size:0.18, alt:0.006,
color:'rgba(205,220,57,0.7)', type:'radiation', priority:3,
popHead:'RadNet: '+s.location, popMeta:'EPA Radiation Monitor',
popText:`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}<br>State: ${s.state}`
});
});
// === ISS + Space Stations (bright white, pulsing) ===
if(spaceDisplayMode === 'icons') (D.space?.stationPositions||[]).forEach(s=>{
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
points.push({
lat:s.lat, lng:s.lon, size:0.4, alt:0.04,
color:'rgba(255,255,255,0.95)', type:'space', priority:1,
popHead:s.name, popMeta:'Space Station (approx.)',
popText:`Orbital position estimate<br>Lat: ${s.lat}° Lon: ${s.lon}°`
});
labels.push({lat:s.lat, lng:s.lon+3, text:s.name.split('(')[0].trim(), size:0.35, color:'rgba(255,255,255,0.7)'});
});
// === GDELT geo events (steel blue) ===
(D.gdelt?.geoPoints||[]).forEach(g=>{
points.push({
lat:g.lat, lng:g.lon, size:0.15+Math.min(g.count/50,0.2), alt:0.007,
color:'rgba(100,149,237,0.6)', type:'gdelt', priority:3,
popHead:'GDELT Event', popMeta:g.count+' reports',
popText:g.name||'Global event detection'
});
});
// === ACLED CONFLICT EVENTS (pulsing rings) ===
const visiblePoints = points.filter(p => shouldShowType(p.type));
const visibleLabels = labels.filter(l => !l.type || shouldShowType(l.type));
// Set points on globe
globe.pointsData(visiblePoints);
globe.labelsData(visibleLabels);
const conflictRings = shouldShowType('conflict') ? (D.acled?.deadliestEvents || []).filter(e => e.lat && e.lon).map(e => {
const logFatal = Math.log2(Math.max(e.fatalities, 1));
return {
lat: e.lat, lng: e.lon,
maxR: Math.max(2, Math.min(6, 1 + logFatal)),
speed: 1.5 + Math.random(),
period: 600 + Math.random()*600,
popHead: e.type || 'CONFLICT', popMeta: 'ACLED Conflict Data',
popText: `${e.fatalities} fatalities<br>${e.location}, ${e.country}<br>Date: ${e.date}`
};
}) : [];
globe.ringsData(conflictRings);
// === FLIGHT CORRIDORS (3D arcs) ===
const arcs = [];
if(flightsVisible && shouldShowType('air')){
const airCoordsFlight = [
{region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120},
{region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24},
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
{region:'South China Sea',lat:14,lon:114}, {region:'Korean Peninsula',lat:37,lon:127},
{region:'Caribbean',lat:25,lon:-80}, {region:'Gulf of Guinea',lat:4,lon:2},
{region:'Cape Route',lat:-34,lon:18}, {region:'Horn of Africa',lat:10,lon:51}
];
const globalHubs = [
{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},
{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}
];
// Inter-hotspot corridors
for(let i=0; i<D.air.length; i++){
for(let j=i+1; j<D.air.length; j++){
const a=D.air[i], b=D.air[j];
const from=airCoordsFlight[i], to=airCoordsFlight[j];
if(!from||!to) continue;
const traffic=a.total+b.total;
if(traffic<30) continue;
const ncRatio=(a.noCallsign+b.noCallsign)/Math.max(traffic,1);
const color = ncRatio>0.15 ? ['rgba(255,95,99,0.6)','rgba(255,95,99,0.15)'] :
ncRatio>0.05 ? ['rgba(255,184,76,0.5)','rgba(255,184,76,0.1)'] :
['rgba(100,240,200,0.4)','rgba(100,240,200,0.08)'];
arcs.push({
startLat:from.lat, startLng:from.lon, endLat:to.lat, endLng:to.lon,
color, stroke:Math.max(0.3, Math.min(1.2, traffic/120)),
label:`${from.region} ↔ ${to.region}: ${traffic} aircraft`
});
}
}
// Hub corridors
D.air.forEach((a,i)=>{
if(!airCoordsFlight[i]||a.total<25) return;
globalHubs.forEach(hub=>{
const dLat=Math.abs(airCoordsFlight[i].lat-hub.lat);
const dLon=Math.abs(airCoordsFlight[i].lon-hub.lon);
if(dLat+dLon<20) return;
arcs.push({
startLat:airCoordsFlight[i].lat, startLng:airCoordsFlight[i].lon,
endLat:hub.lat, endLng:hub.lon,
color:['rgba(100,240,200,0.2)','rgba(100,240,200,0.05)'],
stroke:0.3
});
});
});
}
if(spaceDisplayMode === 'orbits' && shouldShowType('space')){
(D.space?.stationPositions||[]).forEach(s=>{
arcs.push({
startLat: Math.max(-65, s.lat - 18), startLng: s.lon - 80,
endLat: Math.min(65, s.lat + 18), endLng: s.lon + 80,
color: ['rgba(224,176,255,0.55)','rgba(224,176,255,0.12)'],
stroke: 0.7,
label: `${s.name} approximate orbital track`
});
});
}
globe.arcsData(arcs);
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
// Zoom-aware marker sizing: scale markers and labels with camera altitude
const onGlobeZoom = () => {
const alt = globe.pointOfView().altitude;
const sf = Math.max(0.6, Math.min(2.5, 1.5 / alt));
globe.pointRadius(d => (d.size || 0.3) * sf);
// Hide labels when zoomed far out to reduce clutter
const showLabels = alt < 1.8;
globe.labelSize(d => showLabels ? (d.size || 0.4) : 0);
// Scale arc strokes with zoom
globe.arcStroke(d => (d.stroke || 0.4) * Math.max(0.5, Math.min(1.5, 1.2 / alt)));
globe.arcDashAnimateTime(lowPerfMode ? 0 : 2000);
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
// Priority-based point visibility: hide low-priority markers when zoomed out
if(alt > 2.0){
globe.pointsData(visiblePoints.filter(p => (p.priority||3) <= 1));
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
} else if(alt > 1.2){
globe.pointsData(visiblePoints.filter(p => (p.priority||3) <= 2));
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
} else {
globe.pointsData(visiblePoints);
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
}
};
if(typeof globe.onZoom==='function') globe.onZoom(onGlobeZoom);
}
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
function showPopup(event,head,text,meta,lat,lng,alt){
const popup=document.getElementById('mapPopup');
const container=document.getElementById('mapContainer');
const rect=container.getBoundingClientRect();
let left, top;
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
if(!isFlat && lat!=null && globe && typeof globe.getScreenCoords==='function'){
const sc=globe.getScreenCoords(lat,lng,alt||0.01);
if(!sc||isNaN(sc.x)||isNaN(sc.y)||sc.x<0||sc.y<0||sc.x>rect.width||sc.y>rect.height){
if(event&&event.clientX!=null){left=event.clientX-rect.left+10;top=event.clientY-rect.top-10;}
else return;
} else {left=sc.x+10;top=sc.y-10;}
} else if(event && event.clientX != null){
left=event.clientX - rect.left + 10;
top=event.clientY - rect.top - 10;
} else {
left=rect.width/2 - 140; top=rect.height/2 - 60;
}
if(left+290>rect.width) left=left-300;
if(top+150>rect.height) top=top-160;
if(left<0) left=10;
if(top<0) top=10;
popup.style.left=left+'px';popup.style.top=top+'px';
popup.querySelector('.pp-head').textContent=head||'';
popup.querySelector('.pp-text').innerHTML=text||'';
popup.querySelector('.pp-meta').textContent=meta||'';
popup.classList.add('show');
}
function closePopup(){document.getElementById('mapPopup').classList.remove('show')}
// === MAP CONTROLS ===
function toggleFlights() {
flightsVisible = !flightsVisible;
const btn = document.getElementById('flightToggle');
btn.classList.toggle('off', !flightsVisible);
if(isFlat){
if(flatG){
flatG.selectAll('*').remove();
drawFlatMap();
}
return;
}
if(!globe){
return;
}
if(flightsVisible) {
plotMarkers(); // re-render with arcs
} else {
globe.arcsData([]); // hide arcs
// Remove air-type points
const pts = globe.pointsData().filter(p => p.type !== 'air');
globe.pointsData(pts);
const lbls = globe.labelsData().filter(l => l.text && !l.text.match(/\d+$/));
globe.labelsData(lbls);
}
}
// === FLAT/GLOBE TOGGLE ===
const flatRegionBounds = {
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
world:[[-180,-60],[180,80]], americas:[[-130,10],[-60,55]], europe:[[-12,34],[45,72]],
middleEast:[[24,10],[65,45]], asiaPacific:[[60,-12],[180,55]], africa:[[-20,-36],[55,38]]
};
function toggleMapMode(){
isFlat = !isFlat;
const btn = document.getElementById('projToggle');
const hint = document.getElementById('mapHint');
btn.textContent = isFlat ? 'GLOBE MODE' : 'FLAT MODE';
hint.textContent = isFlat ? 'SCROLL TO ZOOM · DRAG TO PAN' : 'DRAG TO ROTATE · SCROLL TO ZOOM';
const globeEl = document.getElementById('globeViz');
const flatEl = document.getElementById('flatMapSvg');
if(isFlat){
if(globe && typeof globe.pauseAnimation === 'function') globe.pauseAnimation();
globeEl.style.display = 'none';
flatEl.style.display = 'block';
setMapLoading(false);
if(!flatSvg) initFlatMap();
else { flatG.selectAll('*').remove(); drawFlatMap(); }
} else {
flatEl.style.display = 'none';
setMapLoading(true, 'Initializing 3D Globe');
requestAnimationFrame(() => {
try {
initGlobe();
if(globe && typeof globe.resumeAnimation === 'function') globe.resumeAnimation();
globeEl.style.display = 'block';
setMapLoading(false);
} catch {
isFlat = true;
globeEl.style.display = 'none';
flatEl.style.display = 'block';
btn.textContent = 'GLOBE MODE';
hint.textContent = '3D LOAD FAILED · FLAT MODE';
if(!flatSvg) initFlatMap();
else { flatG.selectAll('*').remove(); drawFlatMap(); }
setMapLoading(false);
}
});
}
}
function initFlatMap(){
const container = document.getElementById('mapContainer');
flatW = container.clientWidth; flatH = container.clientHeight || 560;
flatSvg = d3.select('#flatMapSvg').attr('viewBox',`0 0 ${flatW} ${flatH}`).attr('preserveAspectRatio','xMidYMid meet');
flatProjection = d3.geoNaturalEarth1().fitSize([flatW-20,flatH-20],{type:'Sphere'}).translate([flatW/2,flatH/2]);
flatPath = d3.geoPath(flatProjection);
flatG = flatSvg.append('g');
flatZoom = d3.zoom().scaleExtent([1,12]).on('zoom',(event)=>{
flatG.attr('transform',event.transform);
const k=event.transform.k;
flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)});
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px')
.style('display',k>=2.5?'block':'none');
}).on('end',()=>{ flatG.selectAll('.marker-label').style('display','block'); });
flatSvg.call(flatZoom);
drawFlatMap();
}
function drawFlatMap(){
flatG.append('path').datum(d3.geoGraticule()()).attr('class','graticule').attr('d',flatPath);
fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
.then(r=>r.json()).then(world=>{
const countries=topojson.feature(world,world.objects.countries);
flatG.selectAll('path.land').data(countries.features).enter().append('path').attr('class','land').attr('d',flatPath);
flatG.append('path').datum(topojson.mesh(world,world.objects.countries,(a,b)=>a!==b)).attr('class','border').attr('d',flatPath);
plotFlatMarkers();
});
}
function plotFlatMarkers(){
const mg=flatG.append('g').attr('class','markers');
const proj=flatProjection;
const addPt=(lat,lon,r,fill,stroke,onClick,priority,type)=>{
if(type && !shouldShowType(type)) return null;
const[x,y]=proj([lon,lat]);if(!x||!y)return null;
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',priority||3);
if(onClick) g.on('click',ev=>{ev.stopPropagation();onClick(ev)});
g.append('circle').attr('class','marker-circle').attr('r',r).attr('data-base-r',r).attr('fill',fill).attr('stroke',stroke).attr('stroke-width',0.8);
return g;
};
// Air
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
if(flightsVisible){
D.air.forEach((a,i)=>{
const c=airCoords[i];if(!c)return;
const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)',
ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'),1);
if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total);
});
}
// Thermal
if(shouldShowType('thermal')) D.thermal.forEach(t=>t.fires.forEach(f=>{
addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)',
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
ev=>showPopup(ev,'Thermal',`${t.region}<br>FRP: ${f.frp.toFixed(1)} MW`,'FIRMS'),3);
}));
// Chokepoints
if(shouldShowType('maritime')) D.chokepoints.forEach(cp=>{
const[x,y]=proj([cp.lon,cp.lat]);if(!x||!y)return;
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
.on('click',ev=>{ev.stopPropagation();showPopup(ev,cp.label,cp.note,'Maritime')});
g.append('rect').attr('x',-4).attr('y',-4).attr('width',8).attr('height',8).attr('fill','rgba(179,136,255,0.7)').attr('stroke','rgba(179,136,255,0.3)').attr('stroke-width',0.5).attr('transform','rotate(45)');
g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','var(--dim)').attr('font-size','8px').attr('font-family','var(--mono)').text(cp.label);
});
// Nuclear
const nukeCoords=[{lat:47.5,lon:34.6},{lat:51.4,lon:30.1},{lat:28.8,lon:50.9},{lat:39.8,lon:125.8},{lat:37.4,lon:141},{lat:31.0,lon:35.1}];
if(shouldShowType('nuke')) D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'),2)});
// SDR
if(shouldShowType('sdr')) D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}<br>${z.region}`,'KiwiSDR'),3)}));
// OSINT
const osintGeo=[{lat:45,lon:41,idx:0},{lat:48,lon:37,idx:1},{lat:48.5,lon:37.5,idx:2},{lat:45,lon:40.2,idx:3},{lat:50.6,lon:36.6,idx:5},{lat:48.5,lon:35,idx:6}];
if(shouldShowType('osint')) osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`),2)});
// WHO
const whoGeo=[{lat:0.3,lon:32.6},{lat:-6.2,lon:106.8},{lat:-4.3,lon:15.3},{lat:35,lon:105},{lat:12.5,lon:105},{lat:35,lon:105},{lat:28,lon:84},{lat:24,lon:45},{lat:30,lon:70},{lat:-0.8,lon:11.6}];
if(shouldShowType('health')) D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'),2)});
// News
if(shouldShowType('news')) (D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region),3)});
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
// NOAA weather
if(shouldShowType('weather')) (D.noaa?.alerts||[]).forEach(a=>{addPt(a.lat,a.lon,4,'rgba(255,152,0,0.7)','rgba(255,152,0,0.3)',ev=>showPopup(ev,a.event,a.headline||'','NOAA/NWS'),2)});
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
// EPA RadNet
if(shouldShowType('environment')) (D.epa?.stations||[]).forEach(s=>{addPt(s.lat,s.lon,3,'rgba(205,220,57,0.6)','rgba(205,220,57,0.2)',ev=>showPopup(ev,'RadNet: '+s.location,`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}`,'EPA'),3)});
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
// Space stations
if(spaceDisplayMode === 'icons' && shouldShowType('space')) (D.space?.stationPositions||[]).forEach(s=>{
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
const g=addPt(s.lat,s.lon,5,'rgba(255,255,255,0.9)','rgba(255,255,255,0.4)',ev=>showPopup(ev,s.name,'Orbital position estimate','Space Station'),1);
if(g) g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','rgba(255,255,255,0.7)').attr('font-size','8px').attr('font-family','var(--mono)').text(s.name.split('(')[0].trim());
});
// GDELT geo events
if(shouldShowType('gdelt')) (D.gdelt?.geoPoints||[]).forEach(g=>{addPt(g.lat,g.lon,2.5,'rgba(100,149,237,0.5)','rgba(100,149,237,0.2)',ev=>showPopup(ev,'GDELT Event',g.name||'','GDELT · '+g.count+' reports'),3)});
// ACLED
if(shouldShowType('conflict')) (D.acled?.deadliestEvents||[]).filter(e=>e.lat&&e.lon).forEach(e=>{
const[x,y]=proj([e.lon,e.lat]);if(!x||!y)return;
const r=Math.max(4,Math.min(14,2+Math.log2(Math.max(e.fatalities,1))*1.5));
Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload.
2026-03-17 19:34:08 -07:00
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
.on('click',ev=>{ev.stopPropagation();showPopup(ev,e.type||'CONFLICT',`${e.fatalities} fatalities<br>${e.location}, ${e.country}`,'ACLED')});
g.append('circle').attr('class','conflict-ring marker-circle').attr('r',r).attr('data-base-r',r).attr('fill','none').attr('stroke','rgba(255,120,80,0.7)').attr('stroke-width',1.5);
g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)');
});
// Flight corridors
if(flightsVisible && shouldShowType('air')){
const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}];
const cG=flatG.append('g').attr('class','corridors-layer');
for(let i=0;i<D.air.length;i++){for(let j=i+1;j<D.air.length;j++){
const a=D.air[i],b=D.air[j],from=airCoordsFlight[i],to=airCoordsFlight[j];
if(!from||!to)continue;const traffic=a.total+b.total;if(traffic<30)continue;
const ncR=(a.noCallsign+b.noCallsign)/Math.max(traffic,1);
const clr=ncR>0.15?'rgba(255,95,99,0.4)':ncR>0.05?'rgba(255,184,76,0.35)':'rgba(100,240,200,0.25)';
const interp=d3.geoInterpolate([from.lon,from.lat],[to.lon,to.lat]);
const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40));
const feat={type:'Feature',geometry:{type:'LineString',coordinates:coords}};
cG.append('path').datum(feat).attr('d',flatPath).attr('fill','none').attr('stroke',clr).attr('stroke-width',Math.max(0.8,Math.min(3,traffic/80)));
}}
D.air.forEach((a,i)=>{if(!airCoordsFlight[i]||a.total<25)return;hubs.forEach(hub=>{
if(Math.abs(airCoordsFlight[i].lat-hub.lat)+Math.abs(airCoordsFlight[i].lon-hub.lon)<20)return;
const interp=d3.geoInterpolate([airCoordsFlight[i].lon,airCoordsFlight[i].lat],[hub.lon,hub.lat]);
const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40));
cG.append('path').datum({type:'Feature',geometry:{type:'LineString',coordinates:coords}}).attr('d',flatPath).attr('fill','none').attr('stroke','rgba(100,240,200,0.15)').attr('stroke-width',0.6);
})});
}
}
// Update setRegion for flat mode
const _origSetRegion = setRegion;
// Override mapZoom for flat mode
const _origMapZoom = mapZoom;
function setRegion(r){
currentRegion = r;
document.querySelectorAll('.region-btn').forEach(b=>b.classList.toggle('active',b.dataset.region===r));
closePopup();
if(isFlat && flatSvg && flatZoom){
if(r==='world'){flatSvg.transition().duration(750).call(flatZoom.transform,d3.zoomIdentity);return;}
const bounds=flatRegionBounds[r];
const p0=flatProjection(bounds[0]),p1=flatProjection(bounds[1]);if(!p0||!p1)return;
const dx=Math.abs(p1[0]-p0[0]),dy=Math.abs(p1[1]-p0[1]);
const cx=(p0[0]+p1[0])/2,cy=(p0[1]+p1[1])/2;
const scale=Math.min(flatW/dx,flatH/dy)*0.85;
flatSvg.transition().duration(750).call(flatZoom.transform,d3.zoomIdentity.translate(flatW/2-scale*cx,flatH/2-scale*cy).scale(scale));
} else {
const pov=regionPOV[r]||regionPOV.world;
globe.pointOfView(pov,1000);
}
}
function mapZoom(factor){
if(isFlat && flatSvg && flatZoom){
flatSvg.transition().duration(300).call(flatZoom.scaleBy,factor);
} else if(globe){
const pov=globe.pointOfView();
globe.pointOfView({altitude:pov.altitude/factor},300);
}
}
// Sparkline SVG generator
function mkSparkSvg(values, isGood){
if(!values || values.length < 2) return '';
const w=52, h=18, pad=2;
const min=Math.min(...values), max=Math.max(...values);
const range=max-min||1;
const pts=values.map((v,i)=>{
const x=pad+(i/(values.length-1))*(w-pad*2);
const y=pad+((max-v)/range)*(h-pad*2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const cls=isGood?'spark-good':'spark-bad';
const last=pts[pts.length-1];
return `<svg class="spark-svg" viewBox="0 0 ${w} ${h}"><polyline class="spark-line ${cls}" points="${pts.join(' ')}"/><circle class="${cls} spark-dot" cx="${last.split(',')[0]}" cy="${last.split(',')[1]}" r="2" fill="${isGood?'var(--accent)':'var(--danger)'}"/></svg>`;
}
// === LOWER GRID ===
function renderLower(){
const mobile = isMobileLayout();
const spread=D.fred.find(f=>f.id==='T10Y2Y');
const ff=D.fred.find(f=>f.id==='DFF');
const ue=D.bls.find(b=>b.id==='LNS14000000');
const cpi=D.bls.find(b=>b.id==='CUUR0000SA0');
const payrolls=D.bls.find(b=>b.id==='CES0000000001');
const gscpi=D.gscpi;
const mkt=D.markets||{};
const metals=D.metals||{};
const wtiH = D.energy.wtiRecent||[];
const wtiMax=Math.max(...wtiH),wtiMin=Math.min(...wtiH);
const sparkHtml=wtiH.map(v=>{
const pct=wtiMax===wtiMin?50:((v-wtiMin)/(wtiMax-wtiMin))*100;
return `<div class="spark-bar" style="height:${Math.max(pct,8)}%"></div>`;
}).join('');
// Helper: format market quote card
const mktCard = (q) => {
if(!q||q.error) return '';
const clr = q.changePct>=0?'var(--accent)':'var(--warn)';
const arrow = q.changePct>=0?'&#9650;':'&#9660;';
2026-04-03 09:25:56 -07:00
const isCrypto = q.symbol.includes('BTC') || q.symbol.includes('ETH');
const isIndex = q.symbol.startsWith('^');
const price = isCrypto
? `$${q.price.toLocaleString()}`
: isIndex
? q.price.toLocaleString(undefined, {maximumFractionDigits: 2})
: `$${q.price}`;
return `<div class="mc"><div class="ml">${q.name||q.symbol}</div><span class="mv" style="color:${clr}">${price}</span><span class="ms" style="color:${clr}">${arrow} ${q.changePct>=0?'+':''}${q.changePct}%</span></div>`;
};
// VIX from Yahoo Finance live data (fallback to FRED)
const vixLive = mkt.vix;
const vixFred = D.fred.find(f=>f.id==='VIXCLS');
const vixVal = vixLive?.value || vixFred?.value;
const vixChg = vixLive?.changePct != null ? `${vixLive.changePct>=0?'+':''}${vixLive.changePct}%` : '';
const fmtMarketPrice = (price) => price != null ? `$${price.toLocaleString(undefined,{maximumFractionDigits:2})}` : '--';
const dayMove = (pct) => pct != null ? `${pct>=0?'+':''}${pct}% today` : '';
const metrics=[
{l:'WTI Crude',v:`$${D.energy.wti}`,s:'$/bbl',p:70},
{l:'Brent',v:`$${D.energy.brent}`,s:'$/bbl',p:75},
{l:'Nat Gas',v:`$${D.energy.natgas||'--'}`,s:'$/MMBtu',p:30},
{l:'Gold',v:fmtMarketPrice(metals.gold),s:dayMove(metals.goldChangePct)||'COMEX proxy',p:58},
{l:'Silver',v:fmtMarketPrice(metals.silver),s:dayMove(metals.silverChangePct)||'COMEX proxy',p:54},
{l:'VIX',v:vixVal?vixVal.toFixed(1):'--',s:vixChg||'volatility index',p:vixVal?Math.min(vixVal*2.5,100):30},
{l:'Fed Funds',v:ff?`${ff.value}%`:'--',s:ff?.date||'',p:36},
{l:'GSCPI',v:gscpi?gscpi.value.toFixed(2):'--',s:gscpi?.interpretation||'',p:49},
{l:'CPI MoM',v:cpi?`+${cpi.momChangePct?.toFixed(2)}%`:'--',s:cpi?.date||'',p:37},
{l:'Unemployment',v:ue?`${ue.value}%`:'--',s:ue?`${ue.momChange>0?'+':''}${ue.momChange} vs prior`:'',p:44},
];
// Attach sparklines from FRED recent data
const fredSpark = (id, up) => {
const f = D.fred.find(f=>f.id===id);
return f?.recent?.length > 1 ? {spark: f.recent, sparkUp: up} : {};
};
metrics[0] = {...metrics[0], spark: D.energy.wtiRecent, sparkUp: false};
metrics[3] = {...metrics[3], spark: metals.goldRecent, sparkUp: (metals.goldChangePct ?? 0) >= 0};
metrics[4] = {...metrics[4], spark: metals.silverRecent, sparkUp: (metals.silverChangePct ?? 0) >= 0};
// Build live market cards from Yahoo Finance
const indexCards = (mkt.indexes||[]).map(mktCard).join('');
const cryptoCards = (mkt.crypto||[]).map(mktCard).join('');
const rateCards = (mkt.rates||[]).map(mktCard).join('');
const hasMarkets = indexCards || cryptoCards;
const srcHtml=D.health.map(s=>`<div class="src-item"><div class="sd ${s.err?'err':'ok'}"></div><span>${s.n}</span></div>`).join('');
// NEWS TICKER — merges RSS + GDELT + Telegram into flowing cards (moved from right rail)
const feed = (D.newsFeed || []).slice(0, 20);
const srcClass = s => {
if (!s) return 'other';
const sl = s.toLowerCase();
// Africa-focused sources first (before generic DW/NYT)
if (sl.includes('dw africa') || sl.includes('africa news') || sl.includes('nyt africa') || sl.includes('rfi')) return 'af';
2026-03-20 06:32:42 -07:00
if (sl.includes('mercopress')) return 'sa';
if (sl.includes('indian express') || sl.includes('the hindu')) return 'ind';
if (sl.includes('sbs')) return 'anz';
if (sl.includes('bbc')) return 'bbc';
if (sl.includes('jazeera') || sl.includes('alj')) return 'alj';
if (sl.includes('gdelt')) return 'gdelt';
if (sl.includes('telegram')) return 'tg';
if (sl.includes('npr')) return 'us';
if (sl.includes('dw') || sl.includes('deutsche')) return 'dw';
if (sl.includes('france') || sl.includes('euronews')) return 'eu';
if (sl.includes('nyt') || sl.includes('times')) return 'nyt';
return 'other';
};
const tickerCards = feed.map(n => {
const sc = srcClass(n.source);
const age = n.timestamp ? getAge(n.timestamp) : '';
const urlAttr = n.url ? ` data-url="${String(n.url).replace(/&/g,'&amp;').replace(/"/g,'&quot;')}"` : '';
return `<div class="tk-card ${n.urgent?'urgent':''} ${n.url?'clickable':''}"${urlAttr}><span class="tk-src ${sc}">${(n.source||'NEWS').substring(0,12)}</span><span class="tk-time">${age}</span><div class="tk-head">${cleanText(n.headline||'')}</div>${n.url?'<span class="tk-link">&#8599;</span>':''}</div>`;
}).join('');
const tickerDuration = Math.max(20, feed.length * 2.5);
// Leverageable Ideas (LLM-only feature)
const hasIdeas = D.ideas && D.ideas.length > 0;
const ideasHtml = hasIdeas ? (D.ideas||[]).map(idea=>`
<div class="idea-card">
<span class="idea-type ${(idea.type||'').toLowerCase()}">${(idea.type||'').toUpperCase()}</span>
${idea.ticker ? `<span class="idea-horizon">${idea.ticker}</span>` : ''}
${idea.horizon ? `<span class="idea-horizon">${idea.horizon}</span>` : ''}
<span class="idea-conf">${idea.confidence} confidence</span>
<div class="idea-title">${idea.title}</div>
<div class="idea-text">${idea.text||idea.rationale||''}</div>
${idea.risk ? `<div class="idea-text" style="color:var(--warn);margin-top:3px">Risk: ${idea.risk}</div>` : ''}
</div>`).join('') : `<div style="padding:20px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:11px">
<div style="font-size:24px;margin-bottom:8px;opacity:0.3">&#9888;</div>
<div>LLM NOT CONFIGURED</div>
<div style="font-size:9px;margin-top:6px;opacity:0.6">Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas</div>
</div>`;
const tickerPanel = `<div class="g-panel lp-ticker" style="display:flex;flex-direction:column">
<div class="sec-head"><h3>${t('panels.newsTicker','Live News Ticker')}</h3><span class="badge">${feed.length} ${t('badges.items','ITEMS')}</span></div>
<div class="ticker-wrap" style="--ticker-duration:${tickerDuration}s">
<div class="ticker-track">${tickerCards}${lowPerfMode ? '' : tickerCards}</div>
</div>
</div>`;
const osintPanel = mobile ? buildOsintPanel('lp-osint', 240) : '';
const macroPanel = `<div class="g-panel lp-macro">
<div class="sec-head"><h3>${t('panels.macroMarkets','Macro + Markets')}</h3><span class="badge">${mkt.timestamp?t('badges.live','LIVE'):t('badges.delayed','DELAYED')}</span></div>
${hasMarkets?`<div style="margin-bottom:8px">
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:4px;letter-spacing:1px">INDEXES</div>
<div class="metrics-row">${indexCards}</div>
</div>
<div style="margin-bottom:8px">
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:4px;letter-spacing:1px">CRYPTO</div>
<div class="metrics-row">${cryptoCards}</div>
</div>`:''}
<div style="margin-bottom:8px">
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:4px;letter-spacing:1px">ENERGY + METALS + MACRO</div>
<div class="metrics-row">${metrics.map(m=>{
const sparkSvg = m.spark ? mkSparkSvg(m.spark, m.sparkUp) : '';
return `<div class="mc"><div class="ml">${m.l}</div><span class="mv">${m.v}${sparkSvg}</span><span class="ms">${m.s}</span><div class="mbar"><span style="width:${m.p}%"></span></div></div>`;
}).join('')}</div>
</div>
<div style="margin-top:6px">
<div style="font-family:var(--mono);font-size:10px;color:var(--dim);margin-bottom:4px">WTI 5-DAY</div>
<div class="spark">${sparkHtml}</div>
</div>
</div>`;
const ideasPanel = `<div class="g-panel lp-ideas">
<div class="sec-head"><h3>${t('panels.tradeIdeas','Leverageable Ideas')}</h3>${D.ideasSource==='llm'?'<span class="ideas-src llm">'+t('ideas.aiEnhanced','AI ENHANCED')+'</span>':D.ideasSource==='disabled'?'<span class="ideas-src static">'+t('ideas.llmOff','LLM OFF')+'</span>':'<span class="ideas-src static">'+t('ideas.pending','PENDING')+'</span>'}</div>
${ideasHtml}
<div class="disclosure">FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.</div>
</div>`;
const sourcePanel = `<div class="g-panel source-health">
<div class="sec-head"><h3>Source Health</h3><span class="badge">${D.meta.sourcesOk}/${D.meta.sourcesQueried} online</span></div>
<div class="src-grid">${srcHtml || '<div class="src-item"><div class="sd err"></div><span>No source snapshot</span></div>'}</div>
<div class="disclosure">Sources that require API keys degrade visibly here while the rest of the sweep continues. The dashboard stays useful with partial data instead of hiding failures.</div>
</div>`;
document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}${sourcePanel}`;
}
async function runTerminalAction(action){
if(terminalBusy) return;
2026-05-17 14:14:45 +02:00
let token = getTerminalActionToken();
if(!token && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1'){
configureTerminalActionToken();
token = getTerminalActionToken();
if(!token) return;
}
terminalBusy = true;
terminalOutput = `> ${action}\nRunning...`;
renderRight();
try{
const res = await fetch('/api/action', {
method:'POST',
headers:{
'Content-Type':'application/json',
2026-05-17 14:14:45 +02:00
...(token ? {'x-crucix-token': token} : {})
},
body:JSON.stringify({action})
});
const payload = await res.json().catch(()=>({error:'Invalid server response'}));
if(!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
if(action === 'status'){
const h = payload.health || {};
terminalOutput = [
'> status',
`State: ${h.status || '--'}`,
`Last sweep: ${h.lastSuccessfulSweep || h.lastSweep || '--'}`,
`Data age: ${h.dataAgeSeconds != null ? h.dataAgeSeconds + 's' : '--'}`,
`Sources: ${h.sourcesOk || 0} ok / ${h.sourcesDegraded || 0} degraded / ${h.sourcesFailed || 0} failed`,
`LLM: ${h.llm?.state || '--'}`,
`Sweep active: ${h.sweepInProgress ? 'yes' : 'no'}`
].join('\n');
}else if(action === 'brief'){
terminalOutput = `> brief\n${payload.text || 'No briefing text returned.'}`;
2026-05-17 14:30:39 +02:00
}else if(action === 'memory'){
const events = payload.recentEvents || [];
const predictions = payload.predictions || [];
terminalOutput = [
'> memory',
`Store: ${payload.memory?.available ? 'available' : 'unavailable'}`,
`Recent events: ${events.length}`,
...events.slice(0,4).map(e => `- ${e.kind}: ${e.name}${e.region ? ' [' + e.region + ']' : ''}`),
`Predictions: ${predictions.length}`,
...predictions.slice(0,4).map(p => `- ${p.outcome_state || 'open'}: ${p.title}`)
].join('\n');
}else if(action === 'sweep'){
terminalOutput = `> sweep\n${payload.status === 'already_running' ? 'Sweep already running.' : 'Sweep accepted. The dashboard will update when the sweep finishes.'}`;
}
}catch(err){
terminalOutput = `> ${action}\nERROR: ${err.message}`;
}finally{
terminalBusy = false;
renderRight();
}
}
// === RIGHT RAIL ===
function renderRight(){
const mobile = isMobileLayout();
// CROSS-SOURCE SIGNALS — moved from lower grid to right rail
const signals=D.tSignals.slice(0,6).map((s,i)=>`<div class="signal-row"><strong>Signal ${i+1}</strong><p>${s}</p></div>`).join('');
// OSINT TICKER — Telegram + WHO as flowing cards
const signalMetrics=[
{l:'Incident Tempo',v:D.tg.urgent.length,p:70},
{l:'Air Theaters',v:D.air.length,p:60},
{l:'Thermal Spikes',v:D.thermal.reduce((s,t)=>s+t.hc,0),p:80},
{l:'SDR Nodes',v:D.sdr.total,p:92},
{l:'Chokepoints',v:D.chokepoints.length,p:50},
{l:'WHO Alerts',v:D.who.length,p:40}
];
// DELTA PANEL — what changed since last sweep
const delta = D.delta || {};
const ds = delta.summary || {};
const hasDelta = ds.totalChanges > 0;
const dirEmoji = {'risk-off':'&#9650;','risk-on':'&#9660;','mixed':'&#9670;'}[ds.direction]||'&#9670;';
const dirClass = {'risk-off':'up','risk-on':'down','mixed':''}[ds.direction]||'';
const escalated = (delta.signals?.escalated || []).slice(0,6);
const deescalated = (delta.signals?.deescalated || []).slice(0,4);
const newSigs = (delta.signals?.new || []).slice(0,4);
const deltaRows = [];
for(const s of newSigs){
deltaRows.push(`<div class="delta-row new"><span class="delta-badge new">NEW</span><span class="delta-label">${s.reason||s.label||s.key}</span></div>`);
}
for(const s of escalated){
const sev = s.severity==='critical'?'style="color:var(--warn);font-weight:600"':s.severity==='high'?'style="color:#ffab40"':'';
const val = s.pctChange!==undefined?`${s.pctChange>0?'+':''}${s.pctChange}%`:`${s.change>0?'+':''}${s.change}`;
deltaRows.push(`<div class="delta-row"><span class="delta-badge up">&#9650;</span><span class="delta-label" ${sev}>${s.label}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
}
for(const s of deescalated){
const val = s.pctChange!==undefined?`${s.pctChange}%`:`${s.change}`;
deltaRows.push(`<div class="delta-row"><span class="delta-badge down">&#9660;</span><span class="delta-label">${s.label||s.key}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
}
const deltaHtml = hasDelta ? deltaRows.join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">${t('delta.noChanges','No changes since last sweep')}</div>`;
2026-05-17 14:49:05 +02:00
const scenarioItems = (D.scenarios?.items || []).filter(s => s.enabled || s.state !== 'dormant').slice(0,4);
const scenarioHtml = scenarioItems.length ? scenarioItems.map(s => `
<div class="signal-row">
<strong>${s.name} <span class="delta-badge ${s.changed?'new':''}">${(s.state||'dormant').toUpperCase()}</span></strong>
<p>${s.description || ''}</p>
<div class="layer-sub">${s.confidence || 0}% confidence · score ${s.score || 0}${s.lastTriggerTime ? ' · ' + getAge(s.lastTriggerTime) : ''}</div>
</div>
`).join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">No active scenario watchlist items</div>`;
document.getElementById('rightRail').innerHTML=`
<div class="g-panel right-actions">
<div class="sec-head"><h3>Terminal Actions</h3><span class="badge">${terminalBusy?'RUNNING':'READY'}</span></div>
<div class="action-grid">
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('status')">Status</button>
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('sweep')">Sweep</button>
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('brief')">Brief</button>
2026-05-17 14:30:39 +02:00
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('memory')">Memory</button>
</div>
2026-05-17 14:14:45 +02:00
<button class="mini-btn" style="margin-bottom:8px" onclick="configureTerminalActionToken()">Configure token</button>
<div class="terminal-output">${terminalOutput.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])).replace(/\n/g,'<br>')}</div>
</div>
<div class="g-panel right-signals">
<div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
${signals}
</div>
2026-05-17 14:49:05 +02:00
<div class="g-panel right-scenarios">
<div class="sec-head"><h3>Scenario Watchlist</h3><span class="badge">${D.scenarios?.available===false?'CONFIG':'LIVE'}</span></div>
${scenarioHtml}
</div>
${mobile ? '' : buildOsintPanel('right-osint', 260)}
<div class="g-panel right-core">
<div class="sec-head"><h3>${t('panels.signalCore','Signal Core')}</h3><span class="badge">${t('badges.hotMetrics','HOT METRICS')}</span></div>
${signalMetrics.map(s=>`<div class="sm"><span class="sml">${s.l}</span><div class="smb"><span style="width:${s.p}%"></span></div><span class="smv">${s.v}</span></div>`).join('')}
</div>
<div class="g-panel right-delta">
<div class="sec-head"><h3>${t('panels.sweepDelta','Sweep Delta')}</h3><span class="badge ${dirClass}">${dirEmoji} ${ds.direction?t('delta.'+ds.direction,ds.direction.toUpperCase()):t('delta.baseline','BASELINE')}</span></div>
${hasDelta?`<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:6px;font-family:var(--mono);font-size:10px">
<span style="color:var(--dim)">${t('delta.changes','Changes')}: <span style="color:var(--accent)">${ds.totalChanges}</span></span>
<span style="color:var(--dim)">${t('delta.critical','Critical')}: <span style="color:${ds.criticalChanges>0?'var(--warn)':'var(--dim)'}">${ds.criticalChanges||0}</span></span>
${ds.signalBreakdown?`<span style="color:var(--dim)">${t('delta.new','New')}: <span style="color:#4dd0e1">${ds.signalBreakdown.new}</span> &#8593;${ds.signalBreakdown.escalated} &#8595;${ds.signalBreakdown.deescalated}</span>`:''}
</div>`:''}
<div class="delta-list">${deltaHtml}</div>
</div>`;
}
// === HELPERS ===
function getAge(d){const ms=Date.now()-new Date(d).getTime();const h=Math.floor(ms/3600000);if(h<1)return 'just now';if(h<24)return h+'h ago';return Math.floor(h/24)+'d ago'}
function cleanText(t){return t.replace(/&#39;/g,"'").replace(/&#33;/g,"!").replace(/&amp;/g,"&").replace(/<[^>]+>/g,'')}
function safeExternalUrl(raw){try{const u=new URL(raw,location.href);return u.protocol==='http:'||u.protocol==='https:'?u.toString():null}catch{return null}}
// === BOOT SEQUENCE ===
function runBoot(){
const acledStatus = D.acled?.totalEvents > 0 ? `<span class="ok">${D.acled.totalEvents} EVENTS</span>` : '<span style="color:var(--warn)">DEGRADED</span>';
const lines=[
{text:t('boot.initializing','INITIALIZING INTELLIGENCE TERMINAL'),delay:0},
{text:t('boot.connecting','CONNECTING {count} OSINT SOURCES...').replace('{count}',D.meta.sourcesQueried),delay:400},
{text:'&#9500;&#9472; '+t('boot.sourceGroup1','OPENSKY · FIRMS · KIWISDR · MARITIME'),delay:700},
{text:'&#9500;&#9472; '+t('boot.sourceGroup2','FRED · BLS · EIA · TREASURY · GSCPI'),delay:900},
{text:'&#9500;&#9472; '+t('boot.sourceGroup3','TELEGRAM · SAFECAST · EPA · WHO · OFAC'),delay:1100},
{text:'&#9492;&#9472; '+t('boot.sourceGroup4','GDELT · NOAA · PATENTS · BLUESKY · REDDIT'),delay:1300},
{text:t('boot.sweepComplete','SWEEP COMPLETE — {ok}/{total} SOURCES').replace('{ok}',`<span class="count">${D.meta.sourcesOk}</span>`).replace('{total}',D.meta.sourcesQueried)+' <span class="ok">'+t('boot.ok','OK')+'</span>',delay:1700},
{text:t('boot.acledLayer','ACLED CONFLICT LAYER')+': '+acledStatus,delay:1900},
{text:t('boot.flightCorridors','FLIGHT CORRIDORS')+': <span class="ok">'+t('boot.active','ACTIVE')+'</span> &#183; '+t('boot.dualProjection','DUAL PROJECTION')+': <span class="ok">'+t('boot.ready','READY')+'</span>',delay:2100},
{text:t('boot.intelligenceSynthesis','INTELLIGENCE SYNTHESIS')+': <span class="ok">'+t('boot.active','ACTIVE')+'</span>',delay:2400},
];
const container=document.getElementById('bootLines');
document.getElementById('bootFinal').textContent=t('dashboard.terminalActive','APP READY');
const tl=gsap.timeline();
tl.to('.logo-ring',{opacity:1,duration:0.6,ease:'power2.out'},0);
tl.to(container,{opacity:1,duration:0.3},0.3);
lines.forEach(line=>{
tl.call(()=>{
const div=document.createElement('div');div.innerHTML=line.text;div.style.opacity='0';
container.appendChild(div);gsap.to(div,{opacity:1,duration:0.2});
},[],line.delay/1000+0.5);
});
tl.to('#bootFinal',{opacity:1,duration:0.4},3.1);
tl.to('#boot',{opacity:0,duration:0.5,ease:'power2.in'},3.7);
tl.set('#boot',{display:'none'},4.2);
tl.to('#bgRadial',{opacity:1,duration:1},3.8);
tl.to('#bgGrid',{opacity:1,duration:1.2},4.0);
tl.to('#main',{opacity:1,duration:0.6},3.9);
tl.call(()=>{
gsap.from('.g-panel,.topbar,.map-container',{opacity:0,y:20,scale:0.97,duration:0.5,stagger:0.06,ease:'power2.out'});
setTimeout(()=>gsap.from('.layer-item,.site-row,.econ-row',{opacity:0,x:-12,duration:0.25,stagger:0.03,ease:'power1.out'}),500);
setTimeout(()=>gsap.from('.ic',{opacity:0,y:12,duration:0.25,stagger:0.03,ease:'power1.out'}),600);
setTimeout(()=>gsap.from('.mc',{opacity:0,y:8,duration:0.25,stagger:0.04,ease:'power1.out'}),800);
setTimeout(()=>gsap.from('.idea-card',{opacity:0,x:12,duration:0.3,stagger:0.06,ease:'power1.out'}),900);
setTimeout(()=>{
document.querySelectorAll('.mbar span,.smb span').forEach(bar=>{const w=bar.style.width;bar.style.width='0%';gsap.to(bar,{width:w,duration:1,ease:'power2.out'})});
document.querySelectorAll('.spark-bar').forEach(bar=>{const h=bar.style.height;bar.style.height='0%';gsap.to(bar,{height:h,duration:0.8,ease:'power2.out'})});
},1000);
},[],4.0);
}
function isMobileLayout(){ return window.innerWidth <= 760; }
function buildOsintPanel(panelClass='', maxHeight=260){
const allPosts=[...D.tg.urgent,...D.tg.topPosts].sort((a,b)=>new Date(b.date||0)-new Date(a.date||0));
const whoItems=D.who.slice(0,4).map(w=>({channel:'WHO ALERT',text:w.title,date:w.date,isWho:true}));
const osintItems=[...allPosts.slice(0,15),...whoItems];
const osintCards=osintItems.map(p=>{
const isU=p.urgentFlags&&p.urgentFlags.length>0;
const views=p.views?p.views>=1000?`${(p.views/1000).toFixed(0)}K`:p.views:'';
const age=p.date?getAge(p.date):'';
const flags=(p.urgentFlags||[]).map(f=>`<span class="tk-src tg" style="margin-right:2px">${f}</span>`).join('');
const srcCls=p.isWho?'style="color:#69f0ae;border-color:rgba(105,240,174,0.4)"':'class="tk-src tg"';
return `<div class="tk-card ${isU?'urgent':''}"><span ${srcCls}>${(p.channel||'OSINT').toUpperCase().substring(0,14)}</span>${views?`<span class="tk-src other">${views}</span>`:''}<span class="tk-time">${age}</span>${flags}<div class="tk-head">${cleanText((p.text||'').substring(0,160))}</div></div>`;
}).join('');
const osintDuration=Math.max(25,osintItems.length*3);
return `<div class="g-panel ${panelClass}" style="display:flex;flex-direction:column">
<div class="sec-head"><h3>${t('panels.osintStream','OSINT Stream')}</h3><span class="badge">${D.tg.urgent.length} ${t('badges.urgent','URGENT')}</span></div>
<div class="ticker-wrap" style="--ticker-duration:${osintDuration}s;max-height:${maxHeight}px">
<div class="ticker-track">${osintCards}${lowPerfMode ? '' : osintCards}</div>
</div>
</div>`;
}
function renderGlossary(){
const body = document.getElementById('glossaryBody');
if(!body) return;
body.innerHTML = signalGuideItems.map(item => `
<div class="glossary-card">
<div class="glossary-term">
<strong>${item.term}</strong>
<span class="glossary-tag">${item.category}</span>
</div>
<div class="glossary-line"><span class="glossary-label">Meaning</span>${item.meaning}</div>
<div class="glossary-line"><span class="glossary-label">Why it matters</span>${item.matters}</div>
<div class="glossary-line"><span class="glossary-label">Not proof of</span>${item.notMeaning}</div>
<div class="glossary-line"><span class="glossary-label">Example</span>${item.example}</div>
</div>
`).join('');
}
function openGlossary(){
const overlay = document.getElementById('glossaryOverlay');
if(!overlay) return;
overlay.classList.add('show');
document.body.style.overflow = 'hidden';
}
function closeGlossary(){
const overlay = document.getElementById('glossaryOverlay');
if(!overlay) return;
overlay.classList.remove('show');
document.body.style.overflow = '';
}
function refreshMapViewport(forceGlobeReflow=false){
const container = document.getElementById('mapContainer');
if(!container) return;
const width = container.clientWidth;
const height = container.clientHeight || (isMobileLayout() ? 420 : 560);
if(globe){
globe.width(width).height(height);
if(forceGlobeReflow && !isFlat){
const globeEl = document.getElementById('globeViz');
globeEl.style.display = 'none';
requestAnimationFrame(() => {
globeEl.style.display = 'block';
globe.width(width).height(height);
});
}
}
if(flatSvg){
flatW = width;
flatH = height;
flatSvg.attr('viewBox',`0 0 ${flatW} ${flatH}`).attr('preserveAspectRatio','xMidYMid meet');
if(flatProjection && flatG){
flatProjection = d3.geoNaturalEarth1().fitSize([flatW-20,flatH-20],{type:'Sphere'}).translate([flatW/2,flatH/2]);
flatPath = d3.geoPath(flatProjection);
flatG.selectAll('*').remove();
drawFlatMap();
}
}
}
let lastResponsiveMobile = null;
function syncResponsiveLayout(force=false){
const mobileNow = isMobileLayout();
if(force || lastResponsiveMobile === null || mobileNow !== lastResponsiveMobile){
lastResponsiveMobile = mobileNow;
renderTopbar();
renderTopMetrics();
renderLeftRail();
renderLower();
renderRight();
playPanelEntrances();
}
refreshMapViewport(force && !isFlat);
positionNavGlider();
}
// === REINIT (for live updates without boot sequence) ===
function reinit(){
renderTopbar();renderTopMetrics();renderLeftRail();renderLower();renderRight();playPanelEntrances();
plotMarkers();
}
// === SSE: Live Updates from Server ===
function connectSSE(){
if (typeof EventSource === 'undefined') return;
// Only connect if served from localhost (not file://)
if (location.protocol === 'file:') return;
const es = new EventSource('/events');
es.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'update' && msg.data) {
D = msg.data;
reinit();
// Flash the topbar to indicate update
const topbar = document.querySelector('.topbar');
if (topbar) {
topbar.style.borderColor = 'var(--accent)';
setTimeout(() => topbar.style.borderColor = '', 1500);
}
} else if (msg.type === 'sweep_start') {
const badge = document.querySelector('.alert-badge');
if (badge) { badge.textContent = 'SWEEPING...'; badge.style.borderColor = 'var(--accent)'; }
}
} catch {}
};
es.onerror = () => {
// Reconnect after 5s on error
es.close();
setTimeout(connectSSE, 5000);
};
}
// === INIT ===
let booted = false;
function init(){
applyTheme(themePreference);
renderAppNav();
renderTopbar();renderTopMetrics();renderLeftRail();renderLower();renderRight();playPanelEntrances();
renderGlossary();
initMap();
if (!booted) { runBoot(); booted = true; }
// Close popup on click outside markers
document.getElementById('mapContainer').addEventListener('click',e=>{
if(!e.target.closest('.map-popup')) closePopup();
});
// Open article links from ticker cards
document.addEventListener('click',e=>{
const card=e.target.closest('.tk-card[data-url]');
if(card){
const url=safeExternalUrl(card.dataset.url);
if(url) window.open(url,'_blank','noopener');
}
});
document.addEventListener('keydown',e=>{
if(e.key === 'Escape') closeGlossary();
});
syncResponsiveLayout(true);
}
document.addEventListener('DOMContentLoaded', () => {
2026-03-17 13:47:55 -07:00
const hasInlineData = !!(D && D.meta);
const canProbeApi = location.protocol !== 'file:';
if (canProbeApi) {
// Server mode: always fetch live data from API before rendering operational data.
fetch('/api/data')
.then(r => { if(!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(data => { D = data; init(); connectSSE(); })
.catch(() => {
// Should not reach here — server routes to loading.html when no data
if (D && D.meta) { D.meta.mode = 'offline-shell'; init(); connectSSE(); }
});
2026-03-17 13:47:55 -07:00
} else if (hasInlineData) {
// File mode renders an explicit offline shell; production data comes from /api/data.
init();
}
});
</script>
</body>
</html>