v0.9.5: The Voltron Update — modular architecture, stable IDs, parallelized boot

- Parallelized startup (60s → 15s) via ThreadPoolExecutor
- Adaptive polling engine with ETag caching (no more bbox interrupts)
- useCallback optimization for interpolation functions
- Sliding LAYERS/INTEL edge panels replace bulky Record Panel
- Modular fetcher architecture (flights, geo, infrastructure, financial, earth_observation)
- Stable entity IDs for GDELT & News popups (PR #63, credit @csysp)
- Admin auth (X-Admin-Key), rate limiting (slowapi), auto-updater
- Docker Swarm secrets support, env_check.py validation
- 85+ vitest tests, CI pipeline, geoJSON builder extraction
- Server-side viewport bbox filtering reduces payloads 80%+

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

Former-commit-id: f2883150b5bc78ebc139d89cc966a76f7d7c0408
This commit is contained in:
anoracleofra-code
2026-03-14 14:01:54 -06:00
parent 60c90661d4
commit 90c2e90e2c
63 changed files with 6015 additions and 2756 deletions
+27 -27
View File
@@ -4,55 +4,55 @@ import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Zap, Ship, Download, Shield, Bug, Heart } from "lucide-react";
const CURRENT_VERSION = "0.9";
const CURRENT_VERSION = "0.9.5";
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const NEW_FEATURES = [
{
icon: <Download size={14} className="text-cyan-400" />,
title: "In-App Auto-Updater",
desc: "One-click updates directly from the dashboard. Downloads the latest release, backs up your files, extracts over the project, and auto-restarts. Manual download fallback included if anything goes wrong.",
icon: <Zap size={14} className="text-cyan-400" />,
title: "Parallelized Boot (15s Cold Start)",
desc: "Backend startup now runs fast-tier, slow-tier, and airport data concurrently via ThreadPoolExecutor. Boot time cut from 60s+ to ~15s.",
color: "cyan",
},
{
icon: <Ship size={14} className="text-blue-400" />,
title: "Granular Ship Layer Controls",
desc: "Ships split into 4 independent toggles: Military/Carriers, Cargo/Tankers, Civilian Vessels, and Cruise/Passenger. Each shows its own live count in the sidebar.",
color: "blue",
},
{
icon: <Shield size={14} className="text-green-400" />,
title: "Stable Entity Selection",
desc: "Ship and flight markers now use MMSI/callsign IDs instead of volatile array indices. Selecting a ship or plane stays locked on even when data refreshes every 60 seconds.",
title: "Adaptive Polling + ETag Caching",
desc: "Data polling engine rebuilt with adaptive retry (3s startup, 15s steady state) and ETag conditional caching. Map panning no longer interrupts data flow.",
color: "green",
},
{
icon: <X size={14} className="text-red-400" />,
title: "Dismissible Threat Alerts",
desc: "Click the X on any threat alert bubble to dismiss it for the session. Uses stable content hashing so dismissed alerts stay hidden across 60-second data refreshes.",
color: "red",
icon: <Ship size={14} className="text-blue-400" />,
title: "Sliding Edge Panels (LAYERS / INTEL)",
desc: "Replaced bulky Record Panel with spring-animated side tabs. LAYERS on the left, INTEL (News, Markets, Radio, Find) on the right. Premium tactical HUD feel.",
color: "blue",
},
{
icon: <Zap size={14} className="text-yellow-400" />,
title: "Faster Data Loading",
desc: "GDELT military incidents now load instantly with background title enrichment instead of blocking for 2+ minutes. Eliminated duplicate startup fetch jobs for faster boot.",
icon: <Download size={14} className="text-yellow-400" />,
title: "Admin Auth + Rate Limiting + Auto-Updater",
desc: "Settings and system endpoints protected by X-Admin-Key. All endpoints rate-limited via slowapi. One-click auto-update from GitHub releases with safe backup/restart.",
color: "yellow",
},
{
icon: <Shield size={14} className="text-purple-400" />,
title: "Docker Swarm Secrets Support",
desc: "Production deployments can now load API keys from /run/secrets/ instead of environment variables. env_check.py enforces warning tiers for missing keys.",
color: "purple",
},
];
const BUG_FIXES = [
"Removed viewport bbox filtering that caused 20-second delays when panning between regions",
"Fixed carrier tracker crash on GDELT 429/TypeError responses",
"Removed fake intelligence assessment generator — all data is now real OSINT only",
"Docker healthcheck start_period increased to 90s to prevent false-negative restarts during data preload",
"ETag collision fix — full payload hash instead of first 256 chars",
"Concurrent /api/refresh guard prevents duplicate data fetches",
"Stable entity IDs for GDELT & News popups — no more wrong popup after data refresh (PR #63)",
"useCallback optimization for interpolation functions — eliminates redundant React re-renders on every 1s tick",
"Restored missing GDELT and datacenter background refreshes in slow-tier loop",
"Server-side viewport bounding box filtering reduces JSON payload size by 80%+",
"Modular fetcher architecture sustained over monolithic data_fetcher.py",
"CCTV ingestors instantiated once at startup — no more fresh DB connections every 10min tick",
];
const CONTRIBUTORS = [
{ name: "@imqdcr", desc: "Ship toggle split into 4 categories + stable MMSI/callsign entity IDs for map markers" },
{ name: "@csysp", desc: "Dismissible threat alert bubbles with stable content hashing + stopPropagation crash fix", pr: "#48" },
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
{ name: "@csysp", desc: "Dismissible threat alerts + stable entity IDs for GDELT & News popups", pr: "#48, #63" },
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min \u2192 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
];
export function useChangelog() {
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -2,7 +2,7 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowUpRight, ArrowDownRight, TrendingUp, Droplet, ChevronDown, ChevronUp } from 'lucide-react';
import { ArrowUpRight, ArrowDownRight, TrendingUp, Droplet, ChevronDown, ChevronUp, Globe } from 'lucide-react';
import type { DashboardData } from "@/types/dashboard";
const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: DashboardData }) {
@@ -23,7 +23,10 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: Dashboar
className="flex justify-between items-center p-3 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
onClick={() => setIsMinimized(!isMinimized)}
>
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
<div className="flex items-center gap-2">
<Globe size={12} className="text-cyan-500" />
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
</div>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
+12 -12
View File
@@ -456,9 +456,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4 }}
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
>
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
<h2 className={`text-xs tracking-widest font-bold ${selectedEntity.type === 'military_flight' ? 'text-red-400' : selectedEntity.type === 'private_flight' ? 'text-orange-400' : selectedEntity.type === 'private_jet' ? 'text-purple-400' : 'text-cyan-400'} flex items-center gap-2`}>
{selectedEntity.type === 'military_flight' ? "MILITARY BOGEY INTERCEPT" : selectedEntity.type === 'private_flight' ? "PRIVATE TRANSPONDER" : selectedEntity.type === 'private_jet' ? "PRIVATE JET TRANSPONDER" : "COMMERCIAL TRANSPONDER"}
</h2>
@@ -576,9 +576,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4 }}
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
>
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
{headerTitle}
</h2>
@@ -648,7 +648,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
}
if (selectedEntity?.type === 'gdelt') {
const gdeltItem = data?.gdelt?.[selectedEntity.id as number];
const gdeltItem = data?.gdelt?.find((g: any) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id);
if (gdeltItem && gdeltItem.properties) {
const props = gdeltItem.properties;
return (
@@ -810,9 +810,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4 }}
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
>
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
AERONAUTICAL HUB
</h2>
@@ -844,9 +844,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4 }}
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
>
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
<AlertTriangle size={14} className="text-red-400" /> {selectedEntity.extra?.last_updated
? new Date(selectedEntity.extra.last_updated + 'Z').toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' }).toUpperCase() + ' — OPTIC INTERCEPT'
@@ -936,10 +936,10 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className={`w-full bg-[var(--bg-panel)] backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
className={`w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
>
<div
className="p-3 border-b border-cyan-500/20 bg-cyan-950/20 relative overflow-hidden cursor-pointer hover:bg-cyan-900/30 transition-colors"
className="p-3 border-b border-[var(--border-primary)]/50 relative overflow-hidden cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex justify-between items-center relative z-10">
@@ -1029,7 +1029,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
</span>
<div className="flex items-center gap-2">
{item.cluster_count > 1 && (
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-cyan-950/50 hover:text-[var(--text-primary)] hover:bg-cyan-900 border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
{isExpanded ? '[- COLLAPSE]' : `[+${item.cluster_count - 1} SOURCES]`}
</button>
)}
@@ -250,18 +250,18 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 1, delay: 0.2 }}
className="w-full flex flex-col bg-[var(--bg-primary)]/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] relative overflow-hidden max-h-full"
className="w-full flex flex-col 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)] relative overflow-hidden max-h-full"
>
<div
className="flex items-center justify-between p-3 border-b border-cyan-900/50 cursor-pointer bg-cyan-950/20 hover:bg-cyan-900/30 transition-colors"
className="flex items-center justify-between p-3 border-b border-[var(--border-primary)]/50 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2 text-cyan-400">
<div className="flex items-center gap-2 text-[var(--text-muted)]">
<RadioReceiver size={14} className={isPlaying ? "animate-pulse" : ""} />
<span className="text-[10px] font-mono tracking-widest font-semibold">SIGINT INTERCEPT</span>
<span className="text-[10px] font-mono tracking-widest">SIGINT INTERCEPT</span>
{isPlaying && <Activity size={12} className="text-red-500 animate-pulse ml-2" />}
</div>
<button className="text-cyan-500 hover:text-cyan-300 transition-colors">
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -275,7 +275,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
className="flex flex-col overflow-hidden"
>
{/* Audio Player Controls */}
<div className="p-4 border-b border-cyan-900/40 bg-[var(--bg-primary)]/60">
<div className="p-4 border-b border-[var(--border-primary)]/40 bg-[var(--bg-primary)]/60">
<div className="flex items-center justify-between mb-3">
<div className="flex flex-col">
<span className="text-xs text-cyan-300 font-mono tracking-wide">
@@ -348,36 +348,6 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
</div>
</div>
{/* KiwiSDR Tuner — appears when a KiwiSDR node is clicked on the map */}
{selectedEntity?.type === 'kiwisdr' && selectedEntity.extra?.url && (
<div className="p-3 border-b border-amber-900/40 bg-amber-950/10">
<div className="text-[9px] text-amber-400 font-mono tracking-widest mb-2 flex items-center gap-2">
<RadioReceiver size={10} />
SDR TUNER: {(selectedEntity.extra.name || 'REMOTE RECEIVER').toUpperCase().slice(0, 60)}
</div>
<div className="text-[8px] text-[var(--text-muted)] font-mono mb-2">
{selectedEntity.extra.location && <span>{selectedEntity.extra.location} · </span>}
{selectedEntity.extra.antenna && <span>{selectedEntity.extra.antenna.slice(0, 80)} · </span>}
{selectedEntity.extra.users !== undefined && <span>{selectedEntity.extra.users}/{selectedEntity.extra.users_max} users</span>}
</div>
<div className="flex items-center gap-2 mt-1">
<a
href={selectedEntity.extra.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center px-4 py-2.5 rounded border border-amber-500/50 bg-amber-950/30 text-amber-400 hover:bg-amber-900/40 hover:border-amber-400 text-[10px] font-mono tracking-widest transition-colors"
>
OPEN SDR RECEIVER
</a>
</div>
{selectedEntity.extra.bands && (
<div className="text-[8px] text-[var(--text-muted)] font-mono mt-2">
BANDS: {(Number(selectedEntity.extra.bands.split('-')[0]) / 1e6).toFixed(0)}-{(Number(selectedEntity.extra.bands.split('-')[1]) / 1e6).toFixed(0)} MHz
</div>
)}
</div>
)}
{/* Feed List */}
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
{feeds.length === 0 ? (
+63 -4
View File
@@ -2,7 +2,7 @@
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 { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight, Palette } from "lucide-react";
import packageJson from "../../package.json";
import { useTheme } from "@/lib/ThemeContext";
@@ -60,11 +60,11 @@ const POTUS_ICAOS: Record<string, { label: string; type: string }> = {
'AE5E77': { label: 'Marine One (VH-92A)', type: 'M1' },
'AE5E79': { label: 'Marine One (VH-92A)', type: 'M1' },
};
import type { DashboardData, ActiveLayers, SelectedEntity } from "@/types/dashboard";
import type { DashboardData, ActiveLayers, SelectedEntity, KiwiSDR } from "@/types/dashboard";
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo }: { data: DashboardData; activeLayers: ActiveLayers; setActiveLayers: React.Dispatch<React.SetStateAction<ActiveLayers>>; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: SelectedEntity) => void; onFlyTo?: (lat: number, lng: number) => void }) {
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo, trackedSdr, setTrackedSdr }: { data: DashboardData; activeLayers: ActiveLayers; setActiveLayers: React.Dispatch<React.SetStateAction<ActiveLayers>>; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: SelectedEntity) => void; onFlyTo?: (lat: number, lng: number) => void; trackedSdr?: KiwiSDR | null; setTrackedSdr?: (sdr: KiwiSDR | null) => void }) {
const [isMinimized, setIsMinimized] = useState(false);
const { theme, toggleTheme } = useTheme();
const { theme, toggleTheme, hudColor, cycleHudColor } = useTheme();
const [gibsPlaying, setGibsPlaying] = useState(false);
const [potusEnabled, setPotusEnabled] = useState(true);
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -172,6 +172,13 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
>
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
</button>
<button
onClick={cycleHudColor}
className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-cyan-400 hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
title={hudColor === 'cyan' ? 'Switch to Matrix HUD' : 'Switch to Cyan HUD'}
>
<Palette size={14} />
</button>
{onSettingsClick && (
<button
onClick={onSettingsClick}
@@ -238,6 +245,58 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
className="overflow-y-auto styled-scrollbar"
>
<div className="flex flex-col gap-6 p-4 pt-2 pb-6">
{/* SDR TRACKER — pinned to TOP when active */}
{trackedSdr && (
<div className="bg-amber-950/20 border border-amber-500/40 rounded-lg p-3 -mt-1 shadow-[0_0_15px_rgba(245,158,11,0.1)]">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Radio size={14} className="text-amber-400" />
<span className="text-[10px] text-amber-400 font-mono tracking-widest font-bold">SDR TRACKER</span>
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-amber-500/20 border border-amber-500/40 text-amber-400 animate-pulse">
LIVE
</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); setTrackedSdr?.(null); }}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-red-400 border border-[var(--border-primary)] hover:border-red-400/40 rounded px-1.5 py-0.5 transition-colors"
title="Release SDR and clear tracking"
>
RELEASE
</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col p-2 rounded-lg border border-amber-500/20 bg-amber-950/10">
<span className="text-[10px] font-bold font-mono text-amber-300 truncate mb-1">
{(trackedSdr.name || 'REMOTE RECEIVER').toUpperCase()}
</span>
<div className="text-[8px] text-[var(--text-muted)] font-mono mb-2">
{trackedSdr.location && <span>{trackedSdr.location} · </span>}
{trackedSdr.antenna && <span>{trackedSdr.antenna.slice(0, 40)}</span>}
</div>
<div className="flex items-center gap-2 mt-1">
<button
onClick={() => onFlyTo?.(trackedSdr.lat, trackedSdr.lon)}
className="flex-1 text-center px-2 py-1.5 rounded border border-[var(--border-primary)] hover:border-amber-400/50 hover:text-amber-400 text-[var(--text-muted)] text-[9px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5"
title="Pan camera to SDR location"
>
<Globe size={10} /> RE-LOCK
</button>
{trackedSdr.url && (
<a
href={trackedSdr.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center px-2 py-1.5 rounded border border-amber-500/50 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 hover:border-amber-400 text-[9px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5"
>
<Activity size={10} /> TUNER
</a>
)}
</div>
</div>
</div>
</div>
)}
{/* 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">
@@ -42,7 +42,7 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
</div>
{/* Right side controls box */}
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto border-r-2 border-r-[var(--border-primary)] flex flex-col relative overflow-hidden h-full">
{/* Header / Toggle */}
<div
@@ -71,14 +71,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
onClick={() => setEffects({ ...effects, bloom: !effects.bloom })}
>
<div className="flex items-center gap-3">
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-gray-600'}`}></span>
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-[var(--text-muted)]'}`}></span>
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'}`}>BLOOM</span>
</div>
<span className="text-[9px] font-mono tracking-wider text-[var(--text-muted)]">{effects.bloom ? 'ON' : 'OFF'}</span>
</div>
{/* Sharpen Slider */}
<div className="flex flex-col gap-3 group border border-cyan-900/50 bg-cyan-950/10 rounded px-4 py-3 pb-4 relative overflow-hidden">
<div className="flex flex-col gap-3 group border border-[var(--border-primary)]/50 bg-[var(--bg-secondary)]/10 rounded px-4 py-3 pb-4 relative overflow-hidden">
<div className="absolute left-0 top-0 bottom-0 w-1 bg-cyan-500"></div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border border-cyan-400 flex items-center justify-center relative">
@@ -98,7 +98,7 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
{/* HUD Dropdown */}
<div className="flex flex-col gap-2 relative">
<div className="flex items-center gap-3 border border-[var(--border-primary)] rounded px-4 py-3 text-[var(--text-muted)] cursor-default">
<span className="w-3 h-3 border border-gray-500 rounded-full flex items-center justify-center"></span>
<span className="w-3 h-3 border border-[var(--border-secondary)] rounded-full flex items-center justify-center"></span>
<span className="text-xs font-mono tracking-widest">HUD</span>
</div>
@@ -106,6 +106,32 @@ export function CarrierLabels({ ships, inView, interpShip }: CarrierLabelsProps)
);
}
// -- Tracked yacht labels --
interface TrackedYachtLabelsProps {
ships: any[];
inView: (lat: number, lng: number) => boolean;
interpShip: (s: any) => [number, number];
}
export function TrackedYachtLabels({ ships, inView, interpShip }: TrackedYachtLabelsProps) {
return (
<>
{ships.map((s: any, i: number) => {
if (!s.yacht_alert || s.lat == null || s.lng == null) return null;
if (!inView(s.lat, s.lng)) return null;
const [iLng, iLat] = interpShip(s);
return (
<Marker key={`yacht-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
<div style={{ ...LABEL_BASE, color: s.yacht_color || '#FF69B4', fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
{s.yacht_owner || s.name || 'TRACKED YACHT'}
</div>
</Marker>
);
})}
</>
);
}
// -- UAV labels --
interface UavLabelsProps {
uavs: any[];
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import {
buildEarthquakesGeoJSON,
buildFirmsGeoJSON,
buildInternetOutagesGeoJSON,
buildDataCentersGeoJSON,
buildShipsGeoJSON,
buildCarriersGeoJSON,
} from '@/components/map/geoJSONBuilders';
import type { Earthquake, FireHotspot, InternetOutage, DataCenter, Ship, ActiveLayers } from '@/types/dashboard';
// Default active layers for ship tests
const allShipLayers: ActiveLayers = {
flights: true, private: true, jets: true, military: true, tracked: true,
satellites: true, earthquakes: true, cctv: false, ukraine_frontline: true,
global_incidents: true, firms_fires: true, jamming: true, internet_outages: true,
datacenters: true, gdelt: false, liveuamap: true, weather: true, uav: true,
kiwisdr: false,
ships_military: true, ships_cargo: true, ships_civilian: true,
ships_passenger: true, ships_tracked_yachts: true,
};
describe('buildEarthquakesGeoJSON', () => {
it('returns null for empty array', () => {
expect(buildEarthquakesGeoJSON([])).toBeNull();
});
it('returns null for undefined', () => {
expect(buildEarthquakesGeoJSON(undefined)).toBeNull();
});
it('builds valid FeatureCollection', () => {
const quakes: Earthquake[] = [
{ id: 'eq1', mag: 5.2, lat: 35.0, lng: 139.0, place: 'Japan' },
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -74.0, place: 'New York' },
];
const result = buildEarthquakesGeoJSON(quakes);
expect(result).not.toBeNull();
expect(result!.type).toBe('FeatureCollection');
expect(result!.features).toHaveLength(2);
expect(result!.features[0].properties?.type).toBe('earthquake');
expect(result!.features[0].geometry).toEqual({ type: 'Point', coordinates: [139.0, 35.0] });
});
it('skips entries with null coordinates', () => {
const quakes: Earthquake[] = [
{ id: 'eq1', mag: 5.2, lat: null as any, lng: 139.0, place: 'Bad' },
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -74.0, place: 'Good' },
];
const result = buildEarthquakesGeoJSON(quakes);
expect(result!.features).toHaveLength(1);
});
});
describe('buildFirmsGeoJSON', () => {
it('returns null for empty array', () => {
expect(buildFirmsGeoJSON([])).toBeNull();
});
it('assigns correct icon by FRP intensity', () => {
const fires: FireHotspot[] = [
{ lat: 10, lng: 20, frp: 2, brightness: 300, confidence: 'high', daynight: 'D', acq_date: '2025-01-01', acq_time: '1200' }, // yellow
{ lat: 10, lng: 21, frp: 10, brightness: 350, confidence: 'high', daynight: 'D', acq_date: '2025-01-01', acq_time: '1200' }, // orange
{ lat: 10, lng: 22, frp: 50, brightness: 400, confidence: 'high', daynight: 'N', acq_date: '2025-01-01', acq_time: '0000' }, // red
{ lat: 10, lng: 23, frp: 200, brightness: 500, confidence: 'high', daynight: 'N', acq_date: '2025-01-01', acq_time: '0000' }, // darkred
];
const result = buildFirmsGeoJSON(fires)!;
expect(result.features[0].properties?.iconId).toBe('fire-yellow');
expect(result.features[1].properties?.iconId).toBe('fire-orange');
expect(result.features[2].properties?.iconId).toBe('fire-red');
expect(result.features[3].properties?.iconId).toBe('fire-darkred');
});
});
describe('buildShipsGeoJSON', () => {
const alwaysInView = () => true;
const interpIdentity = (s: Ship): [number, number] => [s.lng!, s.lat!];
it('returns null when all ship layers are off', () => {
const layers = { ...allShipLayers, ships_military: false, ships_cargo: false, ships_civilian: false, ships_passenger: false, ships_tracked_yachts: false };
const ships: Ship[] = [{ name: 'Test', lat: 10, lng: 20, type: 'cargo' } as Ship];
expect(buildShipsGeoJSON(ships, layers, alwaysInView, interpIdentity)).toBeNull();
});
it('filters out carriers (handled by buildCarriersGeoJSON)', () => {
const ships: Ship[] = [
{ name: 'Cargo Ship', lat: 10, lng: 20, type: 'cargo', mmsi: '123' } as Ship,
{ name: 'USS Nimitz', lat: 30, lng: 40, type: 'carrier', mmsi: '456' } as Ship,
];
const result = buildShipsGeoJSON(ships, allShipLayers, alwaysInView, interpIdentity);
expect(result!.features).toHaveLength(1);
expect(result!.features[0].properties?.name).toBe('Cargo Ship');
});
it('assigns correct icon by ship type', () => {
const ships: Ship[] = [
{ name: 'Tanker', lat: 10, lng: 20, type: 'tanker', mmsi: '1' } as Ship,
{ name: 'Yacht', lat: 10, lng: 21, type: 'yacht', mmsi: '2' } as Ship,
{ name: 'Warship', lat: 10, lng: 22, type: 'military_vessel', mmsi: '3' } as Ship,
];
const result = buildShipsGeoJSON(ships, allShipLayers, alwaysInView, interpIdentity)!;
expect(result.features[0].properties?.iconId).toBe('svgShipRed');
expect(result.features[1].properties?.iconId).toBe('svgShipWhite');
expect(result.features[2].properties?.iconId).toBe('svgShipYellow');
});
});
describe('buildCarriersGeoJSON', () => {
it('returns null for empty ships', () => {
expect(buildCarriersGeoJSON([])).toBeNull();
});
it('only includes carriers', () => {
const ships: Ship[] = [
{ name: 'USS Nimitz', lat: 30, lng: 40, type: 'carrier', mmsi: '456', heading: 90 } as Ship,
{ name: 'Cargo Ship', lat: 10, lng: 20, type: 'cargo', mmsi: '123' } as Ship,
];
const result = buildCarriersGeoJSON(ships)!;
expect(result.features).toHaveLength(1);
expect(result.features[0].properties?.name).toBe('USS Nimitz');
expect(result.features[0].properties?.iconId).toBe('svgCarrier');
});
});
@@ -0,0 +1,423 @@
// ─── Pure GeoJSON builder functions ─────────────────────────────────────────
// Extracted from MaplibreViewer to reduce component size and enable unit testing.
// Each function takes data arrays + optional helpers and returns a GeoJSON FeatureCollection or null.
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR, FrontlineGeoJSON, UAV, Satellite, Ship, ActiveLayers } from "@/types/dashboard";
import { classifyAircraft } from "@/utils/aircraftClassification";
import { MISSION_COLORS, MISSION_ICON_MAP } from "@/components/map/icons/SatelliteIcons";
type FC = GeoJSON.FeatureCollection | null;
type InViewFilter = (lat: number, lng: number) => boolean;
// ─── Earthquakes ────────────────────────────────────────────────────────────
export function buildEarthquakesGeoJSON(earthquakes?: Earthquake[]): FC {
if (!earthquakes?.length) return null;
return {
type: 'FeatureCollection',
features: earthquakes.map((eq, i) => {
if (eq.lat == null || eq.lng == null) return null;
return {
type: 'Feature' as const,
properties: {
id: i,
type: 'earthquake',
name: `[M${eq.mag}]\n${eq.place || 'Unknown Location'}`,
title: eq.title,
},
geometry: { type: 'Point' as const, coordinates: [eq.lng, eq.lat] }
};
}).filter(Boolean) as GeoJSON.Feature[]
};
}
// ─── GPS Jamming Zones ──────────────────────────────────────────────────────
export function buildJammingGeoJSON(zones?: GPSJammingZone[]): FC {
if (!zones?.length) return null;
return {
type: 'FeatureCollection',
features: zones.map((zone, i) => {
const halfDeg = 0.5;
return {
type: 'Feature' as const,
properties: {
id: i,
severity: zone.severity,
ratio: zone.ratio,
degraded: zone.degraded,
total: zone.total,
opacity: zone.severity === 'high' ? 0.45 : zone.severity === 'medium' ? 0.3 : 0.18
},
geometry: {
type: 'Polygon' as const,
coordinates: [[
[zone.lng - halfDeg, zone.lat - halfDeg],
[zone.lng + halfDeg, zone.lat - halfDeg],
[zone.lng + halfDeg, zone.lat + halfDeg],
[zone.lng - halfDeg, zone.lat + halfDeg],
[zone.lng - halfDeg, zone.lat - halfDeg]
]]
}
};
})
};
}
// ─── CCTV Cameras ──────────────────────────────────────────────────────────
export function buildCctvGeoJSON(cameras?: CCTVCamera[], inView?: InViewFilter): FC {
if (!cameras?.length) return null;
return {
type: 'FeatureCollection' as const,
features: cameras.filter(c => c.lat != null && c.lon != null && (!inView || inView(c.lat, c.lon))).map((c, i) => ({
type: 'Feature' as const,
properties: {
id: c.id || i,
type: 'cctv',
name: c.direction_facing || 'Camera',
source_agency: c.source_agency || 'Unknown',
media_url: c.media_url || '',
media_type: c.media_type || 'image'
},
geometry: { type: 'Point' as const, coordinates: [c.lon, c.lat] }
}))
};
}
// ─── KiwiSDR Receivers ─────────────────────────────────────────────────────
export function buildKiwisdrGeoJSON(receivers?: KiwiSDR[], inView?: InViewFilter): FC {
if (!receivers?.length) return null;
return {
type: 'FeatureCollection' as const,
features: receivers.filter(k => k.lat != null && k.lon != null && (!inView || inView(k.lat, k.lon))).map((k, i) => ({
type: 'Feature' as const,
properties: {
id: i,
type: 'kiwisdr',
name: k.name || 'Unknown SDR',
url: k.url || '',
users: k.users || 0,
users_max: k.users_max || 0,
bands: k.bands || '',
antenna: k.antenna || '',
location: k.location || '',
lat: k.lat,
lon: k.lon,
},
geometry: { type: 'Point' as const, coordinates: [k.lon, k.lat] }
}))
};
}
// ─── NASA FIRMS Fires ───────────────────────────────────────────────────────
export function buildFirmsGeoJSON(fires?: FireHotspot[]): FC {
if (!fires?.length) return null;
return {
type: 'FeatureCollection',
features: fires.map((f, i) => {
const frp = f.frp || 0;
const iconId = frp >= 100 ? 'fire-darkred' : frp >= 20 ? 'fire-red' : frp >= 5 ? 'fire-orange' : 'fire-yellow';
return {
type: 'Feature' as const,
properties: {
id: i,
type: 'firms_fire',
name: `Fire ${frp.toFixed(1)} MW`,
frp,
iconId,
brightness: f.brightness || 0,
confidence: f.confidence || '',
daynight: f.daynight === 'D' ? 'Day' : 'Night',
acq_date: f.acq_date || '',
acq_time: f.acq_time || '',
},
geometry: { type: 'Point' as const, coordinates: [f.lng, f.lat] }
};
})
};
}
// ─── Internet Outages ───────────────────────────────────────────────────────
export function buildInternetOutagesGeoJSON(outages?: InternetOutage[]): FC {
if (!outages?.length) return null;
return {
type: 'FeatureCollection',
features: outages.map((o) => {
if (o.lat == null || o.lng == null) return null;
const severity = o.severity || 0;
const region = o.region_name || o.region_code || '?';
const country = o.country_name || o.country_code || '';
const label = `${region}, ${country}`;
const detail = `${label}\n${severity}% drop · ${o.datasource || 'IODA'}`;
return {
type: 'Feature' as const,
properties: {
id: o.region_code || region,
type: 'internet_outage',
name: label,
country,
region,
level: o.level,
severity,
datasource: o.datasource || '',
detail,
},
geometry: { type: 'Point' as const, coordinates: [o.lng, o.lat] }
};
}).filter(Boolean) as GeoJSON.Feature[]
};
}
// ─── Data Centers ───────────────────────────────────────────────────────────
export function buildDataCentersGeoJSON(datacenters?: DataCenter[]): FC {
if (!datacenters?.length) return null;
return {
type: 'FeatureCollection',
features: datacenters.map((dc, i) => ({
type: 'Feature' as const,
properties: {
id: `dc-${i}`,
type: 'datacenter',
name: dc.name || 'Unknown',
company: dc.company || '',
street: dc.street || '',
city: dc.city || '',
country: dc.country || '',
zip: dc.zip || '',
},
geometry: { type: 'Point' as const, coordinates: [dc.lng, dc.lat] }
}))
};
}
// ─── GDELT Incidents ────────────────────────────────────────────────────────
export function buildGdeltGeoJSON(gdelt?: GDELTIncident[], inView?: InViewFilter): FC {
if (!gdelt?.length) return null;
return {
type: 'FeatureCollection',
features: gdelt.map((g) => {
if (!g.geometry || !g.geometry.coordinates) return null;
const [gLng, gLat] = g.geometry.coordinates;
if (inView && !inView(gLat, gLng)) return null;
return {
type: 'Feature' as const,
properties: { id: g.properties?.name || String(g.geometry.coordinates), type: 'gdelt', title: g.properties?.name || '' },
geometry: g.geometry
};
}).filter(Boolean) as GeoJSON.Feature[]
};
}
// ─── LiveUAMap Incidents ────────────────────────────────────────────────────
export function buildLiveuaGeoJSON(incidents?: LiveUAmapIncident[], inView?: InViewFilter): FC {
if (!incidents?.length) return null;
return {
type: 'FeatureCollection',
features: incidents.map((incident) => {
if (incident.lat == null || incident.lng == null) return null;
if (inView && !inView(incident.lat, incident.lng)) return null;
const isViolent = /bomb|missil|strike|attack|kill|destroy|fire|shoot|expl|raid/i.test(incident.title || "");
return {
type: 'Feature' as const,
properties: {
id: incident.id,
type: 'liveuamap',
title: incident.title || '',
iconId: isViolent ? 'icon-liveua-red' : 'icon-liveua-yellow',
},
geometry: { type: 'Point' as const, coordinates: [incident.lng, incident.lat] }
};
}).filter(Boolean) as GeoJSON.Feature[]
};
}
// ─── Ukraine Frontline ──────────────────────────────────────────────────────
export function buildFrontlineGeoJSON(frontlines?: FrontlineGeoJSON | null): FC {
if (!frontlines?.features?.length) return null;
return frontlines;
}
// ─── Parameterized Flight Layer ─────────────────────────────────────────────
// Deduplicates commercial / private / jets / military flight GeoJSON builders.
export interface FlightLayerConfig {
colorMap: Record<string, string>;
groundedMap: Record<string, string>;
typeLabel: string;
idPrefix: string;
/** For military flights: special icon overrides by military_type */
milSpecialMap?: Record<string, string>;
/** If true, prefer true_track over heading for rotation (commercial flights) */
useTrackHeading?: boolean;
}
export function buildFlightLayerGeoJSON(
flights: any[] | undefined,
config: FlightLayerConfig,
helpers: {
interpFlight: (f: any) => [number, number];
inView: InViewFilter;
trackedIcaoSet: Set<string>;
}
): FC {
if (!flights?.length) return null;
const { colorMap, groundedMap, typeLabel, idPrefix, milSpecialMap, useTrackHeading } = config;
const { interpFlight, inView, trackedIcaoSet } = helpers;
return {
type: 'FeatureCollection',
features: flights.map((f: any, i: number) => {
if (f.lat == null || f.lng == null) return null;
if (!inView(f.lat, f.lng)) return null;
if (f.icao24 && trackedIcaoSet.has(f.icao24.toLowerCase())) return null;
const acType = classifyAircraft(f.model, f.aircraft_category);
const grounded = f.alt != null && f.alt <= 100;
let iconId: string;
if (milSpecialMap) {
const milType = f.military_type || 'default';
iconId = milSpecialMap[milType] || '';
if (!iconId) {
iconId = grounded ? groundedMap[acType] : colorMap[acType];
} else if (grounded) {
iconId = groundedMap[acType];
}
} else {
iconId = grounded ? groundedMap[acType] : colorMap[acType];
}
const rotation = useTrackHeading ? (f.true_track || f.heading || 0) : (f.heading || 0);
const [iLng, iLat] = interpFlight(f);
return {
type: 'Feature' as const,
properties: { id: f.icao24 || f.callsign || `${idPrefix}${i}`, type: typeLabel, callsign: f.callsign || f.icao24, rotation, iconId },
geometry: { type: 'Point' as const, coordinates: [iLng, iLat] }
};
}).filter(Boolean) as GeoJSON.Feature[]
};
}
// ─── UAVs / Drones ──────────────────────────────────────────────────────────
export function buildUavGeoJSON(uavs?: UAV[], inView?: InViewFilter): FC {
if (!uavs?.length) return null;
return {
type: 'FeatureCollection',
features: uavs.map((uav, i) => {
if (uav.lat == null || uav.lng == null) return null;
if (inView && !inView(uav.lat, uav.lng)) return null;
return {
type: 'Feature' as const,
properties: {
id: (uav as any).id || `uav-${i}`,
type: 'uav',
callsign: uav.callsign,
rotation: uav.heading || 0,
iconId: 'svgDrone',
name: uav.aircraft_model || uav.callsign,
country: uav.country || '',
uav_type: uav.uav_type || '',
alt: uav.alt || 0,
wiki: uav.wiki || '',
speed_knots: uav.speed_knots || 0,
icao24: uav.icao24 || '',
registration: uav.registration || '',
squawk: uav.squawk || '',
},
geometry: { type: 'Point' as const, coordinates: [uav.lng, uav.lat] }
};
}).filter(Boolean) as GeoJSON.Feature[]
};
}
// ─── Satellites ─────────────────────────────────────────────────────────────
export function buildSatellitesGeoJSON(
satellites: Satellite[] | undefined,
inView: InViewFilter,
interpSat: (s: Satellite) => [number, number]
): FC {
if (!satellites?.length) return null;
return {
type: 'FeatureCollection',
features: satellites
.filter((s) => s.lat != null && s.lng != null && inView(s.lat, s.lng))
.map((s, i) => ({
type: 'Feature' as const,
properties: {
id: s.id || i, type: 'satellite', name: s.name, mission: s.mission || 'general',
sat_type: s.sat_type || 'Satellite', country: s.country || '', alt_km: s.alt_km || 0,
wiki: s.wiki || '', color: MISSION_COLORS[s.mission] || '#aaaaaa',
iconId: MISSION_ICON_MAP[s.mission] || 'sat-gen'
},
geometry: { type: 'Point' as const, coordinates: interpSat(s) }
}))
};
}
// ─── Ships (non-carrier) ────────────────────────────────────────────────────
export function buildShipsGeoJSON(
ships: Ship[] | undefined,
activeLayers: ActiveLayers,
inView: InViewFilter,
interpShip: (s: Ship) => [number, number]
): FC {
if (!(activeLayers.ships_military || activeLayers.ships_cargo || activeLayers.ships_civilian || activeLayers.ships_passenger || activeLayers.ships_tracked_yachts) || !ships) return null;
return {
type: 'FeatureCollection',
features: ships.map((s, i) => {
if (s.lat == null || s.lng == null) return null;
if (!inView(s.lat, s.lng)) return null;
const isTrackedYacht = !!s.yacht_alert;
const isMilitary = s.type === 'carrier' || s.type === 'military_vessel';
const isCargo = s.type === 'tanker' || s.type === 'cargo';
const isPassenger = s.type === 'passenger';
if (s.type === 'carrier') return null; // Handled by buildCarriersGeoJSON
if (isTrackedYacht) {
if (activeLayers?.ships_tracked_yachts === false) return null;
} else if (isMilitary && activeLayers?.ships_military === false) return null;
else if (isCargo && activeLayers?.ships_cargo === false) return null;
else if (isPassenger && activeLayers?.ships_passenger === false) return null;
else if (!isMilitary && !isCargo && !isPassenger && activeLayers?.ships_civilian === false) return null;
let iconId = 'svgShipBlue';
if (isTrackedYacht) iconId = 'svgShipPink';
else if (isCargo) iconId = 'svgShipRed';
else if (s.type === 'yacht' || isPassenger) iconId = 'svgShipWhite';
else if (isMilitary) iconId = 'svgShipYellow';
const [iLng, iLat] = interpShip(s);
return {
type: 'Feature',
properties: { id: s.mmsi || s.name || `ship-${i}`, type: 'ship', name: s.name, rotation: s.heading || 0, iconId },
geometry: { type: 'Point', coordinates: [iLng, iLat] }
};
}).filter(Boolean) as GeoJSON.Feature[]
};
}
// ─── Carriers ───────────────────────────────────────────────────────────────
export function buildCarriersGeoJSON(ships: Ship[] | undefined): FC {
if (!ships?.length) return null;
return {
type: 'FeatureCollection',
features: ships.map((s, i) => {
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
return {
type: 'Feature',
properties: { id: s.mmsi || s.name || `carrier-${i}`, type: 'ship', name: s.name, rotation: s.heading || 0, iconId: 'svgCarrier' },
geometry: { type: 'Point', coordinates: [s.lng, s.lat] }
};
}).filter(Boolean) as GeoJSON.Feature[]
};
}
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { MapRef } from "react-map-gl/maplibre";
export interface ClusterItem {
lng: number;
lat: number;
count: string | number;
id: number;
}
/**
* Extracts cluster label positions from a MapLibre clustered source.
* Listens for moveend/sourcedata events to keep labels in sync.
*
* @param mapRef - React ref to the MapLibre map instance
* @param sourceId - The source ID to query clusters from (e.g. "ships", "earthquakes")
* @param geoJSON - The GeoJSON data driving the source (null = no clusters)
*/
export function useClusterLabels(
mapRef: React.RefObject<MapRef | null>,
sourceId: string,
geoJSON: unknown | null
): ClusterItem[] {
const [clusters, setClusters] = useState<ClusterItem[]>([]);
const handlerRef = useRef<(() => void) | null>(null);
useEffect(() => {
const map = mapRef.current?.getMap();
if (!map || !geoJSON) {
setClusters([]);
return;
}
// Remove previous handler if it exists
if (handlerRef.current) {
map.off("moveend", handlerRef.current);
map.off("sourcedata", handlerRef.current);
}
const update = () => {
try {
const features = map.querySourceFeatures(sourceId);
const raw = features
.filter((f: any) => f.properties?.cluster)
.map((f: any) => ({
lng: (f.geometry as any).coordinates[0],
lat: (f.geometry as any).coordinates[1],
count: f.properties.point_count_abbreviated || f.properties.point_count,
id: f.properties.cluster_id,
}));
const seen = new Set<number>();
const unique = raw.filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
});
setClusters(unique);
} catch {
setClusters([]);
}
};
handlerRef.current = update;
map.on("moveend", update);
map.on("sourcedata", update);
setTimeout(update, 500);
return () => {
map.off("moveend", update);
map.off("sourcedata", update);
};
}, [geoJSON, sourceId]);
return clusters;
}
@@ -0,0 +1,68 @@
"use client";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { interpolatePosition } from "@/utils/positioning";
import { INTERP_TICK_MS } from "@/lib/constants";
/**
* Custom hook that provides position interpolation for flights, ships, and satellites.
* Tracks elapsed time since last data refresh and provides helper functions
* to smoothly animate entity positions between API updates.
*/
export function useInterpolation() {
// Interpolation tick — bumps every INTERP_TICK_MS to animate entity positions
const [interpTick, setInterpTick] = useState(0);
const dataTimestamp = useRef(Date.now());
useEffect(() => {
const iv = setInterval(() => setInterpTick((t) => t + 1), INTERP_TICK_MS);
return () => clearInterval(iv);
}, []);
/** Call this when new data arrives to reset the interpolation baseline */
const resetTimestamp = useCallback(() => {
dataTimestamp.current = Date.now();
}, []);
// Elapsed seconds since last data refresh (used for position interpolation)
const dtSeconds = useMemo(() => {
void interpTick; // use the tick to trigger recalc
return (Date.now() - dataTimestamp.current) / 1000;
}, [interpTick]);
/** Interpolate a flight's position if airborne and has speed + heading */
const interpFlight = useCallback(
(f: { lat: number; lng: number; speed_knots?: number | null; alt?: number | null; true_track?: number; heading?: number }): [number, number] => {
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
if (dtSeconds < 1) return [f.lng, f.lat];
const heading = f.true_track || f.heading || 0;
const [newLat, newLng] = interpolatePosition(f.lat, f.lng, heading, f.speed_knots, dtSeconds);
return [newLng, newLat];
},
[dtSeconds]
);
/** Interpolate a ship's position using SOG + COG */
const interpShip = useCallback(
(s: { lat: number; lng: number; sog?: number; cog?: number; heading?: number }): [number, number] => {
if (typeof s.sog !== "number" || !s.sog || s.sog <= 0 || dtSeconds <= 0) return [s.lng, s.lat];
const heading = (typeof s.cog === "number" ? s.cog : 0) || s.heading || 0;
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, heading, s.sog, dtSeconds);
return [newLng, newLat];
},
[dtSeconds]
);
/** Interpolate a satellite's position between API updates */
const interpSat = useCallback(
(s: { lat: number; lng: number; speed_knots?: number; heading?: number }): [number, number] => {
if (!s.speed_knots || s.speed_knots <= 0 || dtSeconds < 1) return [s.lng, s.lat];
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, s.heading || 0, s.speed_knots, dtSeconds, 0, 65);
return [newLng, newLat];
},
[dtSeconds]
);
return { interpTick, interpFlight, interpShip, interpSat, dtSeconds, resetTimestamp, dataTimestamp };
}
@@ -31,6 +31,7 @@ export const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xm
export const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="34" viewBox="0 0 24 24" fill="none"><path d="M7 22 L7 6 L12 1 L17 6 L17 22 Z" fill="yellow" stroke="#000" stroke-width="1"/><rect x="9" y="8" width="6" height="8" fill="#555" stroke="#000" stroke-width="1"/><circle cx="12" cy="18" r="1.5" fill="#000"/><line x1="12" y1="18" x2="12" y2="24" stroke="#000" stroke-width="1.5"/></svg>`)}`;
export const svgShipBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#3b82f6" stroke="#000" stroke-width="1"/></svg>`)}`;
export const svgShipWhite = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="36" viewBox="0 0 24 24" fill="none"><path d="M5 21 L5 8 L12 2 L19 8 L19 21 C19 23 5 23 5 21 Z" fill="white" stroke="#000" stroke-width="1"/><rect x="7" y="10" width="10" height="8" fill="#90cdf4" stroke="#000" stroke-width="1"/><circle cx="12" cy="14" r="2" fill="yellow" stroke="#000"/></svg>`)}`;
export const svgShipPink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="36" viewBox="0 0 24 24" fill="none"><path d="M5 21 L5 8 L12 2 L19 8 L19 21 C19 23 5 23 5 21 Z" fill="#FF69B4" stroke="#000" stroke-width="1"/><rect x="7" y="10" width="10" height="8" fill="#ff8dc7" stroke="#000" stroke-width="1"/><circle cx="12" cy="14" r="2" fill="white" stroke="#000"/></svg>`)}`;
export const svgCarrier = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="orange" stroke="black"><polygon points="3,21 21,21 20,4 16,4 16,3 12,3 12,4 4,4" /><rect x="15" y="6" width="3" height="10" /></svg>`)}`;
export const svgCctv = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="cyan" stroke-width="2"><path d="M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-.894.553H5.652a1 1 0 0 1-.894-.553L2.724 13.447A1 1 0 0 1 3.618 12h3.632M14 12V8a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a4 4 0 1 0 8 0Z" /></svg>`)}`;
export const svgRadioTower = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="1.5"><line x1="12" y1="10" x2="12" y2="23" stroke="#f59e0b" stroke-width="2"/><line x1="8" y1="23" x2="16" y2="23" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/><line x1="9" y1="16" x2="15" y2="16" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="9" r="2" fill="#f59e0b" stroke="none"/><path d="M8 6a5.5 5.5 0 0 1 8 0" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round"/><path d="M5.5 3.5a9 9 0 0 1 13 0" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/></svg>`)}`;