Files
Shadowbroker/frontend/src/components/WorldviewLeftPanel.tsx
T
anoracleofra-code 34db99deaf v0.8.0: POTUS fleet tracking, full aircraft color-coding, carrier fidelity, UI overhaul
New features:
- POTUS fleet (AF1, AF2, Marine One) with hot-pink icons + gold halo ring
- 9-color aircraft system: military, medical, police, VIP, privacy, dictators
- Sentinel-2 fullscreen overlay with download/copy/open buttons (green themed)
- Carrier homeport deconfliction — distinct pier positions instead of stacking
- Toggle all data layers button (cyan when active, excludes MODIS Terra)
- Version badge + update checker + Discussions shortcut in UI
- Overhauled MapLegend with POTUS fleet, wildfires, infrastructure sections
- Data center map layer with ~700 global DCs from curated dataset

Fixes:
- All Air Force Two ICAO hex codes now correctly identified
- POTUS icon priority over grounded state
- Sentinel-2 no longer overlaps bottom coordinate bar
- Region dossier Nominatim 429 rate-limit retry/backoff
- Docker ENV legacy format warnings resolved
- UI buttons cyan in dark mode, grey in light mode
- Circuit breaker for flaky upstream APIs

Community: @suranyami — parallel multi-arch Docker builds + runtime BACKEND_URL fix (PR #35, #44)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 7c523df70a2d26f675603166e3513d29230592cd
2026-03-12 09:31:37 -06:00

406 lines
29 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight } from "lucide-react";
import packageJson from "../../package.json";
import { useTheme } from "@/lib/ThemeContext";
function relativeTime(iso: string | undefined): string {
if (!iso) return "";
const diff = Date.now() - new Date(iso + "Z").getTime();
if (diff < 0) return "now";
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
// Map layer IDs to freshness keys from the backend source_timestamps dict
const FRESHNESS_MAP: Record<string, string> = {
flights: "commercial_flights",
private: "private_flights",
jets: "private_jets",
military: "military_flights",
tracked: "military_flights",
earthquakes: "earthquakes",
satellites: "satellites",
ships_important: "ships",
ships_civilian: "ships",
ships_passenger: "ships",
ukraine_frontline: "frontlines",
global_incidents: "gdelt",
cctv: "cctv",
gps_jamming: "commercial_flights",
kiwisdr: "kiwisdr",
firms: "firms_fires",
internet_outages: "internet_outages",
datacenters: "datacenters",
};
// POTUS fleet ICAO hex codes for client-side filtering
const POTUS_ICAOS: Record<string, { label: string; type: string }> = {
'ADFDF8': { label: 'Air Force One (82-8000)', type: 'AF1' },
'ADFDF9': { label: 'Air Force One (92-9000)', type: 'AF1' },
'ADFEB7': { label: 'Air Force Two (98-0001)', type: 'AF2' },
'ADFEB8': { label: 'Air Force Two (98-0002)', type: 'AF2' },
'ADFEB9': { label: 'Air Force Two (99-0003)', type: 'AF2' },
'ADFEBA': { label: 'Air Force Two (99-0004)', type: 'AF2' },
'AE4AE6': { label: 'Air Force Two (09-0015)', type: 'AF2' },
'AE4AE8': { label: 'Air Force Two (09-0016)', type: 'AF2' },
'AE4AEA': { label: 'Air Force Two (09-0017)', type: 'AF2' },
'AE4AEC': { label: 'Air Force Two (19-0018)', type: 'AF2' },
'AE0865': { label: 'Marine One (VH-3D)', type: 'M1' },
'AE5E76': { label: 'Marine One (VH-92A)', type: 'M1' },
'AE5E77': { label: 'Marine One (VH-92A)', type: 'M1' },
'AE5E79': { label: 'Marine One (VH-92A)', type: 'M1' },
};
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: { type: string; id: number; extra?: any }) => void; onFlyTo?: (lat: number, lng: number) => void }) {
const [isMinimized, setIsMinimized] = useState(false);
const { theme, toggleTheme } = useTheme();
const [gibsPlaying, setGibsPlaying] = useState(false);
const [potusEnabled, setPotusEnabled] = useState(true);
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// GIBS time slider play/pause animation
useEffect(() => {
if (!gibsPlaying || !setGibsDate) {
if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current);
gibsIntervalRef.current = null;
return;
}
gibsIntervalRef.current = setInterval(() => {
if (!gibsDate) return;
const d = new Date(gibsDate + 'T00:00:00');
d.setDate(d.getDate() + 1);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (d > yesterday) {
const start = new Date();
start.setDate(start.getDate() - 30);
setGibsDate(start.toISOString().slice(0, 10));
} else {
setGibsDate(d.toISOString().slice(0, 10));
}
}, 1500);
return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); };
}, [gibsPlaying, gibsDate, setGibsDate]);
// Compute ship category counts (memoized — ships array can be 1000+ items)
const { importantShipCount, passengerShipCount, civilianShipCount } = useMemo(() => {
const ships = data?.ships;
if (!ships || !ships.length) return { importantShipCount: 0, passengerShipCount: 0, civilianShipCount: 0 };
let important = 0, passenger = 0, civilian = 0;
for (const s of ships) {
const t = s.type;
if (t === 'carrier' || t === 'military_vessel' || t === 'tanker' || t === 'cargo') important++;
else if (t === 'passenger') passenger++;
else civilian++;
}
return { importantShipCount: important, passengerShipCount: passenger, civilianShipCount: civilian };
}, [data?.ships]);
// Find POTUS fleet planes currently airborne from tracked flights
const potusFlights = useMemo(() => {
const tracked = data?.tracked_flights;
if (!tracked) return [];
const results: { index: number; flight: any; meta: { label: string; type: string } }[] = [];
for (let i = 0; i < tracked.length; i++) {
const f = tracked[i];
const icao = (f.icao24 || '').toUpperCase();
if (POTUS_ICAOS[icao]) {
results.push({ index: i, flight: f, meta: POTUS_ICAOS[icao] });
}
}
return results;
}, [data?.tracked_flights]);
const layers = [
{ id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane },
{ id: "private", name: "Private Flights", source: "adsb.lol", count: data?.private_flights?.length || 0, icon: Plane },
{ id: "jets", name: "Private Jets", source: "adsb.lol", count: data?.private_jets?.length || 0, icon: Plane },
{ id: "military", name: "Military Flights", source: "adsb.lol", count: data?.military_flights?.length || 0, icon: AlertTriangle },
{ id: "tracked", name: "Tracked Aircraft", source: "Plane-Alert DB", count: data?.tracked_flights?.length || 0, icon: Eye },
{ id: "earthquakes", name: "Earthquakes (24h)", source: "USGS", count: data?.earthquakes?.length || 0, icon: Activity },
{ id: "satellites", name: "Satellites", source: data?.satellite_source === "celestrak" ? "CelesTrak SGP4" : data?.satellite_source === "tle_api" ? "TLE API · SGP4" : data?.satellite_source === "disk_cache" ? "Cached · SGP4 (est.)" : "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite },
{ id: "ships_important", name: "Carriers / Mil / Cargo", source: "AIS Stream", count: importantShipCount, icon: Ship },
{ id: "ships_civilian", name: "Civilian Vessels", source: "AIS Stream", count: civilianShipCount, icon: Anchor },
{ id: "ships_passenger", name: "Cruise / Passenger", source: "AIS Stream", count: passengerShipCount, icon: Anchor },
{ id: "ukraine_frontline", name: "Ukraine Frontline", source: "DeepStateMap", count: data?.frontlines ? 1 : 0, icon: AlertTriangle },
{ id: "global_incidents", name: "Global Incidents", source: "GDELT", count: data?.gdelt?.length || 0, icon: Activity },
{ id: "cctv", name: "CCTV Mesh", source: "CCTV Mesh + Street View", count: data?.cctv?.length || 0, icon: Cctv },
{ id: "gps_jamming", name: "GPS Jamming", source: "ADS-B NACp", count: data?.gps_jamming?.length || 0, icon: Radio },
{ id: "gibs_imagery", name: "MODIS Terra (Daily)", source: "NASA GIBS", count: null, icon: Globe },
{ id: "highres_satellite", name: "High-Res Satellite", source: "Esri World Imagery", count: null, icon: Satellite },
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
{ id: "firms", name: "Fire Hotspots (24h)", source: "NASA FIRMS VIIRS", count: data?.firms_fires?.length || 0, icon: Flame },
{ id: "internet_outages", name: "Internet Outages", source: "IODA / Georgia Tech", count: data?.internet_outages?.length || 0, icon: Wifi },
{ id: "datacenters", name: "Data Centers", source: "DC Map (GitHub)", count: data?.datacenters?.length || 0, icon: Server },
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
];
const shipIcon = <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1 .6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1" /><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-9-4-9 4c0 2.9.94 5.34 2.81 7.76" /><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6" /></svg>;
return (
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 1 }}
className="w-full flex-1 min-h-0 flex flex-col pointer-events-none"
>
{/* Header */}
<div className="mb-6 pointer-events-auto">
<div className="text-[10px] text-[var(--text-secondary)] font-mono tracking-widest mb-1">TOP SECRET // SI-TK // NOFORN</div>
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest mb-4">KH11-4094 OPS-4168</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1>
<button
onClick={toggleTheme}
className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
</button>
{onSettingsClick && (
<button
onClick={onSettingsClick}
className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)] group`}
title="System Settings"
>
<Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" />
</button>
)}
{onLegendClick && (
<button
onClick={onLegendClick}
className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
title="Map Legend / Icon Key"
>
<BookOpen size={12} />
<span className="text-[8px] font-mono tracking-widest font-bold">KEY</span>
</button>
)}
<span className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] flex items-center justify-center text-[8px] ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} font-mono tracking-widest select-none`}>
v{packageJson.version}
</span>
</div>
</div>
{/* Data Layers Box */}
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] flex flex-col relative overflow-hidden max-h-full">
{/* Header / Toggle */}
<div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
>
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest" onClick={() => setIsMinimized(!isMinimized)}>DATA LAYERS</span>
<div className="flex items-center gap-2">
<button
title={Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? "Disable all layers" : "Enable all layers"}
className={`${Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-400 transition-colors`}
onClick={(e) => {
e.stopPropagation();
const allOn = Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v);
setActiveLayers((prev: any) => {
const next: any = {};
for (const k of Object.keys(prev)) {
next[k] = k === 'gibs_imagery' ? false : !allOn;
}
return next;
});
}}
>
{Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
</button>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" onClick={() => setIsMinimized(!isMinimized)}>
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
</div>
<AnimatePresence>
{!isMinimized && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-y-auto styled-scrollbar"
>
<div className="flex flex-col gap-6 p-4 pt-2 pb-6">
{/* POTUS Fleet — pinned to TOP when aircraft are active */}
{potusEnabled && potusFlights.length > 0 && (
<div className="bg-[#ff1493]/5 border border-[#ff1493]/30 rounded-lg p-3 -mt-1">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shield size={14} className="text-[#ff1493]" />
<span className="text-[10px] text-[#ff1493] font-mono tracking-widest font-bold">POTUS FLEET</span>
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-[#ff1493]/20 border border-[#ff1493]/40 text-[#ff1493] animate-pulse">
{potusFlights.length} ACTIVE
</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); setPotusEnabled(false); }}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
title="Hide POTUS Fleet tracker"
>
HIDE
</button>
</div>
<div className="flex flex-col gap-2">
{potusFlights.map((pf) => {
const color = pf.meta.type === 'AF1' ? '#ff1493' : pf.meta.type === 'M1' ? '#ff1493' : '#3b82f6';
const alt = pf.flight.alt_baro || pf.flight.alt || 0;
const speed = pf.flight.gs || pf.flight.speed || 0;
return (
<div
key={pf.flight.icao24}
className="flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all hover:bg-[var(--bg-secondary)]/60"
style={{ borderColor: `${color}40`, background: `${color}10` }}
onClick={() => {
if (onFlyTo && pf.flight.lat != null && pf.flight.lng != null) {
onFlyTo(pf.flight.lat, pf.flight.lng);
}
if (onEntityClick) {
onEntityClick({ type: 'tracked_flight', id: pf.index });
}
}}
>
<div className="flex flex-col">
<span className="text-[10px] font-bold font-mono" style={{ color }}>{pf.meta.label}</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">
{alt > 0 ? `${Math.round(alt).toLocaleString()} ft` : 'GND'} · {speed > 0 ? `${Math.round(speed)} kts` : 'STATIC'}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: color }} />
<span className="text-[8px] font-mono" style={{ color }}>TRACK</span>
</div>
</div>
);
})}
</div>
</div>
)}
{layers.map((layer, idx) => {
const Icon = layer.icon;
const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
return (
<div key={idx} className="flex flex-col">
<div
className="flex items-start justify-between group cursor-pointer"
onClick={() => setActiveLayers((prev: any) => ({ ...prev, [layer.id]: !active }))}
>
<div className="flex gap-3">
<div className={`mt-1 ${active ? 'text-cyan-400' : 'text-gray-600 group-hover:text-gray-400'} transition-colors`}>
{(['ships_important', 'ships_civilian', 'ships_passenger'].includes(layer.id)) ? shipIcon : <Icon size={16} strokeWidth={1.5} />}
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? (() => {
const fKey = FRESHNESS_MAP[layer.id];
const freshness = fKey && data?.freshness?.[fKey];
const rt = freshness ? relativeTime(freshness) : '';
return rt ? <span className="text-cyan-500/70">{rt}</span> : 'LIVE';
})() : 'OFF'}</span>
</div>
</div>
<div className="flex items-center gap-3">
{active && layer.count > 0 && (
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
)}
<div className={`text-[9px] font-mono tracking-wider px-2 py-0.5 rounded-full border ${active
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/30 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
: 'border-[var(--border-primary)] text-[var(--text-muted)] bg-transparent'
}`}>
{active ? 'ON' : 'OFF'}
</div>
</div>
</div>
{/* GIBS Imagery inline controls: time slider + play/pause + opacity */}
{active && layer.id === 'gibs_imagery' && gibsDate && setGibsDate && setGibsOpacity && (
<div className="ml-7 mt-2 flex flex-col gap-2" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-2">
<button
onClick={() => setGibsPlaying(p => !p)}
className="w-5 h-5 flex items-center justify-center rounded border border-cyan-500/30 text-cyan-400 hover:bg-cyan-950/30 transition-colors"
>
{gibsPlaying ? <Pause size={10} /> : <Play size={10} />}
</button>
<input
type="range"
min={0}
max={29}
value={(() => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const selected = new Date(gibsDate + 'T00:00:00');
const diff = Math.round((yesterday.getTime() - selected.getTime()) / 86400000);
return 29 - Math.max(0, Math.min(29, diff));
})()}
onChange={e => {
const daysAgo = 29 - parseInt(e.target.value);
const d = new Date();
d.setDate(d.getDate() - 1 - daysAgo);
setGibsDate(d.toISOString().slice(0, 10));
}}
className="flex-1 h-1 accent-cyan-500 cursor-pointer"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[8px] text-cyan-400 font-mono">{gibsDate}</span>
<div className="flex items-center gap-1">
<span className="text-[8px] text-[var(--text-muted)] font-mono">OPC</span>
<input
type="range"
min={0}
max={100}
value={Math.round((gibsOpacity ?? 0.6) * 100)}
onChange={e => setGibsOpacity(parseInt(e.target.value) / 100)}
className="w-16 h-1 accent-cyan-500 cursor-pointer"
/>
</div>
</div>
</div>
)}
</div>
)
})}
{/* POTUS Fleet — bottom section when inactive or hidden */}
{(potusFlights.length === 0 || !potusEnabled) && (
<div className="border-t border-[var(--border-primary)]/50 pt-4 mt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield size={14} className="text-[var(--text-muted)]" />
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">POTUS FLEET</span>
</div>
{!potusEnabled ? (
<button
onClick={(e) => { e.stopPropagation(); setPotusEnabled(true); }}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
>
SHOW
</button>
) : (
<span className="text-[8px] font-mono text-[var(--text-muted)]">NO ACTIVE AIRCRAFT</span>
)}
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
});
export default WorldviewLeftPanel;