mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-09 07:43:59 +02:00
Initial commit: ShadowBroker v0.1
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,73 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Styled thin scrollbar for dark HUD UI */
|
||||
.styled-scrollbar::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.5);
|
||||
}
|
||||
|
||||
.styled-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* MapLibre Popup Overrides */
|
||||
.maplibregl-popup-content {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-tip {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Focus mode: dim the map canvas (tiles + drawn layers) when a popup is active.
|
||||
Inside MapLibre's DOM, .maplibregl-canvas-container is a SIBLING of .maplibregl-popup,
|
||||
so this filter dims the map without affecting the popup at all. */
|
||||
.map-focus-active .maplibregl-canvas-container {
|
||||
filter: brightness(0.3);
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
/* Keep popups fully bright and interactive above the dimmed canvas */
|
||||
.map-focus-active .maplibregl-popup {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "WORLDVIEW // ORBITAL TRACKING",
|
||||
description: "Advanced Geopolitical Risk Dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script src="https://cesium.com/downloads/cesiumjs/releases/1.115/Build/Cesium/Cesium.js" async></script>
|
||||
<link href="https://cesium.com/downloads/cesiumjs/releases/1.115/Build/Cesium/Widgets/widgets.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-black`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import dynamic from 'next/dynamic';
|
||||
import { motion } from "framer-motion";
|
||||
import WorldviewLeftPanel from "@/components/WorldviewLeftPanel";
|
||||
import WorldviewRightPanel from "@/components/WorldviewRightPanel";
|
||||
import NewsFeed from "@/components/NewsFeed";
|
||||
import MarketsPanel from "@/components/MarketsPanel";
|
||||
import FilterPanel from "@/components/FilterPanel";
|
||||
import FindLocateBar from "@/components/FindLocateBar";
|
||||
import RadioInterceptPanel from "@/components/RadioInterceptPanel";
|
||||
import SettingsPanel from "@/components/SettingsPanel";
|
||||
import MapLegend from "@/components/MapLegend";
|
||||
import ScaleBar from "@/components/ScaleBar";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
|
||||
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
|
||||
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
|
||||
|
||||
export default function Dashboard() {
|
||||
const dataRef = useRef<any>({});
|
||||
const [dataVersion, setDataVersion] = useState(0);
|
||||
// Stable reference for child components — only changes when dataVersion increments
|
||||
const data = dataRef.current;
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [legendOpen, setLegendOpen] = useState(false);
|
||||
const [mapView, setMapView] = useState({ zoom: 2, latitude: 20 });
|
||||
const [measureMode, setMeasureMode] = useState(false);
|
||||
const [measurePoints, setMeasurePoints] = useState<{ lat: number; lng: number }[]>([]);
|
||||
|
||||
const [activeLayers, setActiveLayers] = useState({
|
||||
flights: true,
|
||||
private: true,
|
||||
jets: true,
|
||||
military: true,
|
||||
tracked: true,
|
||||
satellites: true,
|
||||
ships_important: true,
|
||||
ships_civilian: false,
|
||||
ships_passenger: true,
|
||||
earthquakes: true,
|
||||
cctv: false,
|
||||
ukraine_frontline: true,
|
||||
global_incidents: true,
|
||||
day_night: true,
|
||||
gps_jamming: true,
|
||||
});
|
||||
|
||||
const [effects, setEffects] = useState({
|
||||
bloom: true,
|
||||
});
|
||||
|
||||
const [activeStyle, setActiveStyle] = useState('DEFAULT');
|
||||
const stylesList = ['DEFAULT', 'FLIR', 'NVG', 'CRT'];
|
||||
|
||||
const cycleStyle = () => {
|
||||
setActiveStyle((prev) => {
|
||||
const idx = stylesList.indexOf(prev);
|
||||
return stylesList[(idx + 1) % stylesList.length];
|
||||
});
|
||||
};
|
||||
|
||||
const [selectedEntity, setSelectedEntity] = useState<{ type: string, id: string | number, extra?: any } | null>(null);
|
||||
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
|
||||
const [flyToLocation, setFlyToLocation] = useState<{ lat: number, lng: number, ts: number } | null>(null);
|
||||
|
||||
// Eavesdrop Mode State
|
||||
const [isEavesdropping, setIsEavesdropping] = useState(false);
|
||||
const [eavesdropLocation, setEavesdropLocation] = useState<{ lat: number, lng: number } | null>(null);
|
||||
const [cameraCenter, setCameraCenter] = useState<{ lat: number, lng: number } | null>(null);
|
||||
|
||||
// Mouse coordinate + reverse geocoding state
|
||||
const [mouseCoords, setMouseCoords] = useState<{ lat: number, lng: number } | null>(null);
|
||||
const [locationLabel, setLocationLabel] = useState('');
|
||||
const geocodeCache = useRef<Map<string, string>>(new Map());
|
||||
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const lastGeocodedPos = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const geocodeAbort = useRef<AbortController | null>(null);
|
||||
|
||||
const handleMouseCoords = useCallback((coords: { lat: number, lng: number }) => {
|
||||
setMouseCoords(coords);
|
||||
|
||||
// Throttle reverse geocoding to every 1500ms + distance check
|
||||
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
|
||||
geocodeTimer.current = setTimeout(async () => {
|
||||
// Skip if cursor hasn't moved far enough (0.05 degrees ~= 5km)
|
||||
if (lastGeocodedPos.current) {
|
||||
const dLat = Math.abs(coords.lat - lastGeocodedPos.current.lat);
|
||||
const dLng = Math.abs(coords.lng - lastGeocodedPos.current.lng);
|
||||
if (dLat < 0.05 && dLng < 0.05) return;
|
||||
}
|
||||
|
||||
const gridKey = `${(coords.lat).toFixed(2)},${(coords.lng).toFixed(2)}`;
|
||||
const cached = geocodeCache.current.get(gridKey);
|
||||
if (cached) {
|
||||
setLocationLabel(cached);
|
||||
lastGeocodedPos.current = coords;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight geocode request
|
||||
if (geocodeAbort.current) geocodeAbort.current.abort();
|
||||
geocodeAbort.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lng}&format=json&zoom=10&addressdetails=1`,
|
||||
{ headers: { 'Accept-Language': 'en' }, signal: geocodeAbort.current.signal }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const addr = data.address || {};
|
||||
const city = addr.city || addr.town || addr.village || addr.county || '';
|
||||
const state = addr.state || addr.region || '';
|
||||
const country = addr.country || '';
|
||||
const parts = [city, state, country].filter(Boolean);
|
||||
const label = parts.join(', ') || data.display_name?.split(',').slice(0, 3).join(',') || 'Unknown';
|
||||
|
||||
// LRU-style cache pruning: keep max 500 entries (Map preserves insertion order)
|
||||
if (geocodeCache.current.size > 500) {
|
||||
const iter = geocodeCache.current.keys();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = iter.next().value;
|
||||
if (key !== undefined) geocodeCache.current.delete(key);
|
||||
}
|
||||
}
|
||||
geocodeCache.current.set(gridKey, label);
|
||||
setLocationLabel(label);
|
||||
lastGeocodedPos.current = coords;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') { /* Silently fail - keep last label */ }
|
||||
}
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
// Region dossier state (right-click intelligence)
|
||||
const [regionDossier, setRegionDossier] = useState<any>(null);
|
||||
const [regionDossierLoading, setRegionDossierLoading] = useState(false);
|
||||
|
||||
const handleMapRightClick = useCallback(async (coords: { lat: number, lng: number }) => {
|
||||
setSelectedEntity({ type: 'region_dossier', id: `${coords.lat.toFixed(4)}_${coords.lng.toFixed(4)}`, extra: coords });
|
||||
setRegionDossierLoading(true);
|
||||
setRegionDossier(null);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRegionDossier(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch region dossier", e);
|
||||
} finally {
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Clear dossier when selecting a different entity type
|
||||
useEffect(() => {
|
||||
if (selectedEntity?.type !== 'region_dossier') {
|
||||
setRegionDossier(null);
|
||||
setRegionDossierLoading(false);
|
||||
}
|
||||
}, [selectedEntity]);
|
||||
|
||||
// ETag tracking for conditional requests
|
||||
const fastEtag = useRef<string | null>(null);
|
||||
const slowEtag = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFastData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const res = await fetch("http://localhost:8000/api/live-data/fast", { headers });
|
||||
if (res.status === 304) return; // Data unchanged, skip update
|
||||
if (res.ok) {
|
||||
fastEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSlowData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
||||
const res = await fetch("http://localhost:8000/api/live-data/slow", { headers });
|
||||
if (res.status === 304) return;
|
||||
if (res.ok) {
|
||||
slowEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFastData();
|
||||
fetchSlowData();
|
||||
|
||||
// Fast polling: 15s (backend updates every 60s — polling more often just yields 304s)
|
||||
// Slow polling: 60s (backend updates every 30min)
|
||||
const fastInterval = setInterval(fetchFastData, 15000);
|
||||
const slowInterval = setInterval(fetchSlowData, 60000);
|
||||
|
||||
return () => {
|
||||
clearInterval(fastInterval);
|
||||
clearInterval(slowInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="fixed inset-0 w-full h-full bg-black overflow-hidden font-sans">
|
||||
|
||||
{/* MAPLIBRE WEBGL OVERLAY */}
|
||||
<ErrorBoundary name="Map">
|
||||
<MaplibreViewer
|
||||
data={data}
|
||||
activeLayers={activeLayers}
|
||||
activeFilters={activeFilters}
|
||||
effects={{ ...effects, bloom: effects.bloom && activeStyle !== 'DEFAULT', style: activeStyle }}
|
||||
onEntityClick={setSelectedEntity}
|
||||
selectedEntity={selectedEntity}
|
||||
flyToLocation={flyToLocation}
|
||||
isEavesdropping={isEavesdropping}
|
||||
onEavesdropClick={setEavesdropLocation}
|
||||
onCameraMove={setCameraCenter}
|
||||
onMouseCoords={handleMouseCoords}
|
||||
onRightClick={handleMapRightClick}
|
||||
regionDossier={regionDossier}
|
||||
regionDossierLoading={regionDossierLoading}
|
||||
onViewStateChange={setMapView}
|
||||
measureMode={measureMode}
|
||||
onMeasureClick={(pt: { lat: number; lng: number }) => {
|
||||
setMeasurePoints(prev => prev.length >= 3 ? prev : [...prev, pt]);
|
||||
}}
|
||||
measurePoints={measurePoints}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
{uiVisible && (
|
||||
<>
|
||||
{/* WORLDVIEW HEADER */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute top-6 left-6 z-[200] pointer-events-none flex items-center gap-4"
|
||||
>
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{/* Target Reticle Icon */}
|
||||
<div className="w-6 h-6 rounded-full border border-cyan-500 relative flex items-center justify-center">
|
||||
<div className="w-4 h-4 rounded-full bg-cyan-500/30"></div>
|
||||
<div className="absolute top-[-2px] bottom-[-2px] w-[1px] bg-cyan-500"></div>
|
||||
<div className="absolute left-[-2px] right-[-2px] h-[1px] bg-cyan-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-2xl font-bold tracking-[0.4em] text-white flex items-center gap-3" style={{ fontFamily: 'monospace' }}>
|
||||
S H A D O W <span className="text-cyan-400">B R O K E R</span>
|
||||
</h1>
|
||||
<span className="text-[9px] text-gray-500 font-mono tracking-[0.3em] mt-1 ml-1">GLOBAL THREAT INTERCEPT</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* SYSTEM METRICS TOP LEFT */}
|
||||
<div className="absolute top-2 left-6 text-[8px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none">
|
||||
OPTIC VIS:113 SRC:180 DENS:1.42 0.8ms
|
||||
</div>
|
||||
|
||||
{/* SYSTEM METRICS TOP RIGHT */}
|
||||
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-gray-600 z-[200] pointer-events-none">
|
||||
<div>RTX</div>
|
||||
<div>VSR</div>
|
||||
</div>
|
||||
|
||||
{/* LEFT HUD CONTAINER */}
|
||||
<div className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none">
|
||||
{/* LEFT PANEL - DATA LAYERS */}
|
||||
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} />
|
||||
|
||||
{/* LEFT BOTTOM - DISPLAY CONFIG */}
|
||||
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
|
||||
</div>
|
||||
|
||||
{/* RIGHT HUD CONTAINER */}
|
||||
<div className="absolute right-6 top-24 bottom-6 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2">
|
||||
{/* FIND / LOCATE */}
|
||||
<div className="flex-shrink-0">
|
||||
<FindLocateBar
|
||||
data={data}
|
||||
onLocate={(lat, lng, entityId, entityType) => {
|
||||
setFlyToLocation({ lat, lng, ts: Date.now() });
|
||||
}}
|
||||
onFilter={(filterKey, value) => {
|
||||
setActiveFilters(prev => {
|
||||
const current = prev[filterKey] || [];
|
||||
if (!current.includes(value)) {
|
||||
return { ...prev, [filterKey]: [...current, value] };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TOP RIGHT - MARKETS */}
|
||||
<div className="flex-shrink-0">
|
||||
<MarketsPanel data={data} />
|
||||
</div>
|
||||
|
||||
{/* SIGINT & RADIO INTERCEPTS */}
|
||||
<div className="flex-shrink-0">
|
||||
<RadioInterceptPanel
|
||||
data={data}
|
||||
isEavesdropping={isEavesdropping}
|
||||
setIsEavesdropping={setIsEavesdropping}
|
||||
eavesdropLocation={eavesdropLocation}
|
||||
cameraCenter={cameraCenter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DATA FILTERS */}
|
||||
<div className="flex-shrink-0">
|
||||
<FilterPanel data={data} activeFilters={activeFilters} setActiveFilters={setActiveFilters} />
|
||||
</div>
|
||||
|
||||
{/* BOTTOM RIGHT - NEWS FEED (fills remaining space) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<NewsFeed data={data} selectedEntity={selectedEntity} regionDossier={regionDossier} regionDossierLoading={regionDossierLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTTOM CENTER COORDINATE / LOCATION BAR */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1, duration: 1 }}
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-auto"
|
||||
>
|
||||
<div
|
||||
className="bg-black/60 backdrop-blur-md border border-gray-800 rounded-xl px-6 py-2.5 flex items-center gap-6 shadow-[0_4px_30px_rgba(0,0,0,0.5)] border-b-2 border-b-cyan-900 cursor-pointer"
|
||||
onClick={cycleStyle}
|
||||
>
|
||||
{/* Coordinates */}
|
||||
<div className="flex flex-col items-center min-w-[120px]">
|
||||
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">COORDINATES</div>
|
||||
<div className="text-[11px] text-cyan-400 font-mono font-bold tracking-wide">
|
||||
{mouseCoords ? `${mouseCoords.lat.toFixed(4)}, ${mouseCoords.lng.toFixed(4)}` : '0.0000, 0.0000'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-8 bg-gray-700" />
|
||||
|
||||
{/* Location name */}
|
||||
<div className="flex flex-col items-center min-w-[180px] max-w-[320px]">
|
||||
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">LOCATION</div>
|
||||
<div className="text-[10px] text-gray-300 font-mono truncate max-w-[320px]">
|
||||
{locationLabel || 'Hover over map...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-8 bg-gray-700" />
|
||||
|
||||
{/* Style preset (compact) */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">STYLE</div>
|
||||
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* RESTORE UI BUTTON (If Hidden) */}
|
||||
{!uiVisible && (
|
||||
<button
|
||||
onClick={() => setUiVisible(true)}
|
||||
className="absolute bottom-6 right-6 z-[200] bg-black/60 backdrop-blur-md border border-gray-800 rounded px-4 py-2 text-[10px] font-mono tracking-widest text-cyan-500 hover:text-cyan-300 hover:border-cyan-800 transition-colors pointer-events-auto"
|
||||
>
|
||||
RESTORE UI
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* DYNAMIC SCALE BAR */}
|
||||
<div className="absolute bottom-[5.5rem] left-[26rem] z-[201] pointer-events-auto">
|
||||
<ScaleBar
|
||||
zoom={mapView.zoom}
|
||||
latitude={mapView.latitude}
|
||||
measureMode={measureMode}
|
||||
measurePoints={measurePoints}
|
||||
onToggleMeasure={() => {
|
||||
setMeasureMode(m => !m);
|
||||
if (measureMode) setMeasurePoints([]);
|
||||
}}
|
||||
onClearMeasure={() => setMeasurePoints([])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* STATIC CRT VIGNETTE */}
|
||||
<div className="absolute inset-0 pointer-events-none z-[2]"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, transparent 40%, rgba(0,0,0,0.8) 100%)'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* SCANLINES OVERLAY */}
|
||||
<div className="absolute inset-0 pointer-events-none z-[3] opacity-5 bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px)]" style={{ backgroundSize: '100% 4px' }}></div>
|
||||
|
||||
{/* SETTINGS PANEL */}
|
||||
<SettingsPanel isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
|
||||
{/* MAP LEGEND */}
|
||||
<MapLegend isOpen={legendOpen} onClose={() => setLegendOpen(false)} />
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, X, Check, GripHorizontal } from 'lucide-react';
|
||||
|
||||
interface FilterField {
|
||||
key: string;
|
||||
label: string;
|
||||
options: string[];
|
||||
optionLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AdvancedFilterModalProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
accentColor: string; // CSS color string e.g. '#00bcd4'
|
||||
accentColorName: string; // tailwind name e.g. 'cyan'
|
||||
fields: FilterField[];
|
||||
activeFilters: Record<string, string[]>;
|
||||
onApply: (filters: Record<string, string[]>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdvancedFilterModal({
|
||||
title, icon, accentColor, accentColorName, fields, activeFilters, onApply, onClose
|
||||
}: AdvancedFilterModalProps) {
|
||||
// Local draft state — only committed on Apply
|
||||
const [draft, setDraft] = useState<Record<string, Set<string>>>(() => {
|
||||
const init: Record<string, Set<string>> = {};
|
||||
for (const field of fields) {
|
||||
init[field.key] = new Set(activeFilters[field.key] || []);
|
||||
}
|
||||
return init;
|
||||
});
|
||||
|
||||
const [searchTerms, setSearchTerms] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
for (const field of fields) init[field.key] = '';
|
||||
return init;
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState(fields[0]?.key || '');
|
||||
|
||||
// Dragging state
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, y: 0, posX: 0, posY: 0 });
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Center on mount
|
||||
useEffect(() => {
|
||||
if (modalRef.current) {
|
||||
const rect = modalRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
x: (window.innerWidth - rect.width) / 2,
|
||||
y: (window.innerHeight - rect.height) / 2
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY, posX: position.x, posY: position.y };
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handleMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
const dy = e.clientY - dragStartRef.current.y;
|
||||
setPosition({
|
||||
x: dragStartRef.current.posX + dx,
|
||||
y: dragStartRef.current.posY + dy
|
||||
});
|
||||
};
|
||||
const handleUp = () => setIsDragging(false);
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const toggleItem = (fieldKey: string, value: string) => {
|
||||
setDraft(prev => {
|
||||
const next = { ...prev };
|
||||
const s = new Set(prev[fieldKey]);
|
||||
if (s.has(value)) s.delete(value);
|
||||
else s.add(value);
|
||||
next[fieldKey] = s;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeChip = (fieldKey: string, value: string) => {
|
||||
setDraft(prev => {
|
||||
const next = { ...prev };
|
||||
const s = new Set(prev[fieldKey]);
|
||||
s.delete(value);
|
||||
next[fieldKey] = s;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearField = (fieldKey: string) => {
|
||||
setDraft(prev => ({ ...prev, [fieldKey]: new Set<string>() }));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
const cleared: Record<string, Set<string>> = {};
|
||||
for (const f of fields) cleared[f.key] = new Set<string>();
|
||||
setDraft(cleared);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, set] of Object.entries(draft)) {
|
||||
if (set.size > 0) result[key] = Array.from(set);
|
||||
}
|
||||
onApply(result);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const totalSelected = Object.values(draft).reduce((acc, s) => acc + s.size, 0);
|
||||
|
||||
const activeField = fields.find(f => f.key === activeTab);
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!activeField) return [];
|
||||
const term = (searchTerms[activeTab] || '').toLowerCase();
|
||||
const opts = activeField.options;
|
||||
if (!term) return opts;
|
||||
return opts.filter(o => {
|
||||
const displayLabel = activeField.optionLabels?.[o] || o;
|
||||
return displayLabel.toLowerCase().includes(term);
|
||||
});
|
||||
}, [activeField, activeTab, searchTerms]);
|
||||
|
||||
const accentBorder = `border-[${accentColor}]/30`;
|
||||
|
||||
// Tailwind color map for dynamic classes
|
||||
const colorMap: Record<string, { text: string; bg: string; bgHover: string; border: string; ring: string }> = {
|
||||
cyan: { text: 'text-cyan-400', bg: 'bg-cyan-500/10', bgHover: 'hover:bg-cyan-500/15', border: 'border-cyan-500/30', ring: 'ring-cyan-500/50' },
|
||||
orange: { text: 'text-orange-400', bg: 'bg-orange-500/10', bgHover: 'hover:bg-orange-500/15', border: 'border-orange-500/30', ring: 'ring-orange-500/50' },
|
||||
yellow: { text: 'text-yellow-400', bg: 'bg-yellow-500/10', bgHover: 'hover:bg-yellow-500/15', border: 'border-yellow-500/30', ring: 'ring-yellow-500/50' },
|
||||
pink: { text: 'text-pink-400', bg: 'bg-pink-500/10', bgHover: 'hover:bg-pink-500/15', border: 'border-pink-500/30', ring: 'ring-pink-500/50' },
|
||||
blue: { text: 'text-blue-400', bg: 'bg-blue-500/10', bgHover: 'hover:bg-blue-500/15', border: 'border-blue-500/30', ring: 'ring-blue-500/50' },
|
||||
};
|
||||
const c = colorMap[accentColorName] || colorMap.cyan;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] pointer-events-auto" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: 480,
|
||||
userSelect: isDragging ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.92 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`bg-[#0a0e14]/95 backdrop-blur-xl border ${c.border} rounded-xl shadow-[0_8px_60px_rgba(0,0,0,0.8)] flex flex-col font-mono overflow-hidden`}
|
||||
style={{ maxHeight: '70vh' }}
|
||||
>
|
||||
{/* ── Title Bar (Draggable) ── */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-grab active:cursor-grabbing border-b border-gray-800/60 select-none flex-shrink-0"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GripHorizontal size={14} className="text-gray-600" />
|
||||
{icon}
|
||||
<span className={`text-[11px] ${c.text} tracking-[0.25em] font-semibold`}>{title}</span>
|
||||
{totalSelected > 0 && (
|
||||
<span className={`text-[9px] ${c.bg} ${c.text} px-1.5 py-0.5 rounded-sm`}>
|
||||
{totalSelected} SELECTED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-600 hover:text-white transition-colors p-1 rounded hover:bg-gray-800">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Tab Bar (for multi-field categories) ── */}
|
||||
{fields.length > 1 && (
|
||||
<div className="flex border-b border-gray-800/40 px-3 pt-2 gap-1 flex-shrink-0">
|
||||
{fields.map(field => {
|
||||
const isActive = activeTab === field.key;
|
||||
const count = draft[field.key]?.size || 0;
|
||||
return (
|
||||
<button
|
||||
key={field.key}
|
||||
onClick={() => setActiveTab(field.key)}
|
||||
className={`px-3 py-1.5 text-[9px] tracking-widest rounded-t transition-colors relative ${isActive
|
||||
? `${c.bg} ${c.text} border border-b-0 ${c.border}`
|
||||
: 'text-gray-500 hover:text-gray-300 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{field.label}
|
||||
{count > 0 && (
|
||||
<span className={`ml-1.5 text-[8px] ${c.text} bg-black/40 px-1 rounded`}>{count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Selected Chips ── */}
|
||||
{activeField && draft[activeTab]?.size > 0 && (
|
||||
<div className="px-4 pt-3 pb-1 flex flex-wrap gap-1.5 flex-shrink-0 max-h-20 overflow-y-auto styled-scrollbar">
|
||||
{Array.from(draft[activeTab]).map(val => {
|
||||
const displayVal = activeField.optionLabels?.[val] || val;
|
||||
return (
|
||||
<span
|
||||
key={val}
|
||||
className={`inline-flex items-center gap-1 text-[9px] ${c.bg} ${c.text} border ${c.border} rounded-full px-2 py-0.5 group`}
|
||||
>
|
||||
{displayVal.length > 28 ? displayVal.slice(0, 28) + '…' : displayVal}
|
||||
<button
|
||||
onClick={() => removeChip(activeTab, val)}
|
||||
className="opacity-50 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => clearField(activeTab)}
|
||||
className="text-[8px] text-red-400/70 hover:text-red-300 tracking-widest ml-1"
|
||||
>
|
||||
CLEAR
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Search Bar ── */}
|
||||
<div className="px-4 pt-3 pb-2 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-600" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerms[activeTab] || ''}
|
||||
onChange={(e) => setSearchTerms(prev => ({ ...prev, [activeTab]: e.target.value }))}
|
||||
placeholder={`Search ${activeField?.label.toLowerCase() || ''}...`}
|
||||
className={`w-full bg-black/50 border border-gray-700/70 rounded-lg text-[11px] text-gray-300 pl-8 pr-8 py-2 font-mono tracking-wide focus:outline-none focus:${c.border} focus:ring-1 ${c.ring} placeholder-gray-600 transition-all`}
|
||||
autoFocus
|
||||
/>
|
||||
{searchTerms[activeTab] && (
|
||||
<button
|
||||
onClick={() => setSearchTerms(prev => ({ ...prev, [activeTab]: '' }))}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-300"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[8px] text-gray-600 tracking-widest">
|
||||
{filteredOptions.length} AVAILABLE
|
||||
</span>
|
||||
<span className="text-[8px] text-gray-600 tracking-widest">
|
||||
{draft[activeTab]?.size || 0} SELECTED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable Checkbox List ── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2 styled-scrollbar" style={{ maxHeight: '35vh' }}>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-600 text-[10px] tracking-widest">
|
||||
NO MATCHING RESULTS
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-px">
|
||||
{filteredOptions.map((option) => {
|
||||
const isChecked = draft[activeTab]?.has(option);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => toggleItem(activeTab, option)}
|
||||
className={`flex items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-all group ${isChecked
|
||||
? `${c.bg} ${c.text}`
|
||||
: `text-gray-400 hover:bg-gray-800/50 hover:text-gray-200`
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className={`w-3.5 h-3.5 rounded-[3px] border flex items-center justify-center flex-shrink-0 transition-all ${isChecked
|
||||
? `${c.border} ${c.bg}`
|
||||
: 'border-gray-700 group-hover:border-gray-500'
|
||||
}`}>
|
||||
{isChecked && <Check size={9} strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="text-[10px] tracking-wide truncate">
|
||||
{activeField?.optionLabels?.[option] || option}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800/60 flex-shrink-0">
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-[9px] text-red-400/70 hover:text-red-300 tracking-widest transition-colors"
|
||||
>
|
||||
CLEAR ALL
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[9px] text-gray-500 hover:text-gray-300 tracking-widest border border-gray-700 rounded-md px-4 py-1.5 hover:bg-gray-800/50 transition-all"
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className={`text-[9px] ${c.text} tracking-widest border ${c.border} rounded-md px-4 py-1.5 ${c.bg} ${c.bgHover} transition-all font-semibold`}
|
||||
>
|
||||
APPLY{totalSelected > 0 ? ` (${totalSelected})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error(`[ErrorBoundary${this.props.name ? `: ${this.props.name}` : ""}]`, error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4 bg-red-950/40 border border-red-800 rounded-lg m-2">
|
||||
<div className="text-center font-mono">
|
||||
<div className="text-red-400 text-xs tracking-widest mb-1">⚠ SYSTEM ERROR</div>
|
||||
<div className="text-gray-400 text-[10px]">{this.props.name || "Component"} failed to render</div>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="mt-2 px-3 py-1 text-[10px] bg-red-900/60 hover:bg-red-800/60 text-red-300 rounded border border-red-700 transition-colors"
|
||||
>
|
||||
RETRY
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronUp, Filter, Plane, Shield, Star, Ship, SlidersHorizontal } from 'lucide-react';
|
||||
import AdvancedFilterModal from './AdvancedFilterModal';
|
||||
import { airlineNames } from '../lib/airlineCodes';
|
||||
import { trackedCategories, trackedOperators } from '../lib/trackedData';
|
||||
|
||||
interface FilterPanelProps {
|
||||
data: any;
|
||||
activeFilters: Record<string, string[]>;
|
||||
setActiveFilters: (filters: Record<string, string[]>) => void;
|
||||
}
|
||||
|
||||
type ModalConfig = {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
accentColor: string;
|
||||
accentColorName: string;
|
||||
fields: { key: string; label: string; options: string[]; optionLabels?: Record<string, string> }[];
|
||||
};
|
||||
|
||||
export default function FilterPanel({ data, activeFilters, setActiveFilters }: FilterPanelProps) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [openModal, setOpenModal] = useState<string | null>(null);
|
||||
|
||||
// ── Extract unique values from live data ──
|
||||
|
||||
// Commercial: departures, arrivals, airlines
|
||||
const uniqueOrigins = useMemo(() => {
|
||||
const origins = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.origin_name && f.origin_name !== 'UNKNOWN') origins.add(f.origin_name);
|
||||
}
|
||||
return Array.from(origins).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
|
||||
const uniqueDestinations = useMemo(() => {
|
||||
const dests = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.dest_name && f.dest_name !== 'UNKNOWN') dests.add(f.dest_name);
|
||||
}
|
||||
return Array.from(dests).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
|
||||
const uniqueAirlines = useMemo(() => {
|
||||
const airlines = new Set<string>();
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
if (f.airline_code && f.airline_code.trim()) airlines.add(f.airline_code.trim());
|
||||
}
|
||||
return Array.from(airlines).sort();
|
||||
}, [data?.commercial_flights]);
|
||||
|
||||
const airlineLabels = useMemo(() => {
|
||||
const labels: Record<string, string> = {};
|
||||
for (const code of uniqueAirlines) {
|
||||
const name = airlineNames[code];
|
||||
if (name) {
|
||||
labels[code] = `${code} - ${name}`;
|
||||
} else {
|
||||
labels[code] = code;
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}, [uniqueAirlines]);
|
||||
|
||||
// Private: callsigns + aircraft types
|
||||
const uniquePrivateCallsigns = useMemo(() => {
|
||||
const callsigns = new Set<string>();
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
if (f.callsign) callsigns.add(f.callsign);
|
||||
if (f.registration) callsigns.add(f.registration);
|
||||
}
|
||||
return Array.from(callsigns).sort();
|
||||
}, [data?.private_flights, data?.private_jets]);
|
||||
|
||||
const uniquePrivateAircraftTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
if (f.model && f.model !== 'Unknown') types.add(f.model);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.private_flights, data?.private_jets]);
|
||||
|
||||
// Military: country + aircraft type
|
||||
const uniqueMilCountries = useMemo(() => {
|
||||
const countries = new Set<string>();
|
||||
for (const f of data?.military_flights || []) {
|
||||
if (f.country) countries.add(f.country);
|
||||
else if (f.registration) countries.add(f.registration);
|
||||
}
|
||||
return Array.from(countries).sort();
|
||||
}, [data?.military_flights]);
|
||||
|
||||
const uniqueMilAircraftTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const f of data?.military_flights || []) {
|
||||
if (f.military_type && f.military_type !== 'default') types.add(f.military_type);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.military_flights]);
|
||||
|
||||
// Tracked: operators + categories
|
||||
const uniqueTrackedOperators = useMemo(() => {
|
||||
const ops = new Set<string>(trackedOperators);
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
if (f.alert_operator) ops.add(f.alert_operator);
|
||||
if (f.alert_tag1) ops.add(f.alert_tag1);
|
||||
if (f.alert_tag2) ops.add(f.alert_tag2);
|
||||
}
|
||||
return Array.from(ops).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
const uniqueTrackedCategories = useMemo(() => {
|
||||
const cats = new Set<string>(trackedCategories);
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
if (f.alert_category) cats.add(f.alert_category);
|
||||
}
|
||||
return Array.from(cats).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
// Maritime: vessel names + vessel types (using 'type' field, not 'ship_type')
|
||||
const uniqueShipNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const s of data?.ships || []) {
|
||||
if (s.name && s.name !== 'UNKNOWN') names.add(s.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
}, [data?.ships]);
|
||||
|
||||
const uniqueVesselTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
for (const s of data?.ships || []) {
|
||||
// Use 'type' field from AIS stream (tanker, cargo, passenger, yacht, etc.)
|
||||
if (s.type && s.type !== 'unknown') types.add(s.type);
|
||||
}
|
||||
return Array.from(types).sort();
|
||||
}, [data?.ships]);
|
||||
|
||||
// ── Modal configs ──
|
||||
|
||||
const modalConfigs: Record<string, ModalConfig> = {
|
||||
commercial: {
|
||||
title: 'COMMERCIAL FLIGHTS',
|
||||
icon: <Plane size={13} className="text-cyan-400" />,
|
||||
accentColor: '#00bcd4',
|
||||
accentColorName: 'cyan',
|
||||
fields: [
|
||||
{ key: 'commercial_departure', label: 'DEPARTURE', options: uniqueOrigins },
|
||||
{ key: 'commercial_arrival', label: 'ARRIVAL', options: uniqueDestinations },
|
||||
{ key: 'commercial_airline', label: 'AIRLINE', options: uniqueAirlines, optionLabels: airlineLabels },
|
||||
]
|
||||
},
|
||||
private: {
|
||||
title: 'PRIVATE / JETS',
|
||||
icon: <Plane size={13} className="text-orange-400" />,
|
||||
accentColor: '#FF8C00',
|
||||
accentColorName: 'orange',
|
||||
fields: [
|
||||
{ key: 'private_callsign', label: 'CALLSIGN / REG', options: uniquePrivateCallsigns },
|
||||
{ key: 'private_aircraft_type', label: 'AIRCRAFT TYPE', options: uniquePrivateAircraftTypes },
|
||||
]
|
||||
},
|
||||
military: {
|
||||
title: 'MILITARY',
|
||||
icon: <Shield size={13} className="text-yellow-400" />,
|
||||
accentColor: '#EAB308',
|
||||
accentColorName: 'yellow',
|
||||
fields: [
|
||||
{ key: 'military_country', label: 'COUNTRY / REG', options: uniqueMilCountries },
|
||||
{ key: 'military_aircraft_type', label: 'AIRCRAFT TYPE', options: uniqueMilAircraftTypes },
|
||||
]
|
||||
},
|
||||
tracked: {
|
||||
title: 'TRACKED AIRCRAFT',
|
||||
icon: <Star size={13} className="text-pink-400" />,
|
||||
accentColor: '#EC4899',
|
||||
accentColorName: 'pink',
|
||||
fields: [
|
||||
{ key: 'tracked_category', label: 'CATEGORY', options: uniqueTrackedCategories },
|
||||
{ key: 'tracked_owner', label: 'OPERATOR / ENTITY', options: uniqueTrackedOperators },
|
||||
]
|
||||
},
|
||||
ships: {
|
||||
title: 'MARITIME VESSELS',
|
||||
icon: <Ship size={13} className="text-blue-400" />,
|
||||
accentColor: '#3B82F6',
|
||||
accentColorName: 'blue',
|
||||
fields: [
|
||||
{ key: 'ship_name', label: 'VESSEL NAME', options: uniqueShipNames },
|
||||
{ key: 'ship_type', label: 'VESSEL TYPE', options: uniqueVesselTypes },
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = () => setActiveFilters({});
|
||||
|
||||
const activeCount = Object.values(activeFilters).reduce((acc, arr) => acc + arr.length, 0);
|
||||
|
||||
const getCountForCategory = (category: string) => {
|
||||
const config = modalConfigs[category];
|
||||
if (!config) return 0;
|
||||
return config.fields.reduce((acc, f) => acc + (activeFilters[f.key]?.length || 0), 0);
|
||||
};
|
||||
|
||||
const handleModalApply = (categoryKey: string, modalFilters: Record<string, string[]>) => {
|
||||
const config = modalConfigs[categoryKey];
|
||||
const next = { ...activeFilters };
|
||||
for (const field of config.fields) {
|
||||
delete next[field.key];
|
||||
}
|
||||
for (const [key, values] of Object.entries(modalFilters)) {
|
||||
if (values.length > 0) next[key] = values;
|
||||
}
|
||||
setActiveFilters(next);
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{ key: 'commercial', title: 'COMMERCIAL FLIGHTS', icon: <Plane size={11} className="text-cyan-400" />, color: 'cyan' },
|
||||
{ key: 'private', title: 'PRIVATE / JETS', icon: <Plane size={11} className="text-orange-400" />, color: 'orange' },
|
||||
{ key: 'military', title: 'MILITARY', icon: <Shield size={11} className="text-yellow-400" />, color: 'yellow' },
|
||||
{ key: 'tracked', title: 'TRACKED AIRCRAFT', icon: <Star size={11} className="text-pink-400" />, color: 'pink' },
|
||||
{ key: 'ships', title: 'MARITIME VESSELS', icon: <Ship size={11} className="text-blue-400" />, color: 'blue' },
|
||||
];
|
||||
|
||||
const borderColors: Record<string, string> = {
|
||||
cyan: 'border-cyan-500/20 hover:border-cyan-500/40',
|
||||
orange: 'border-orange-500/20 hover:border-orange-500/40',
|
||||
yellow: 'border-yellow-500/20 hover:border-yellow-500/40',
|
||||
pink: 'border-pink-500/20 hover:border-pink-500/40',
|
||||
blue: 'border-blue-500/20 hover:border-blue-500/40',
|
||||
};
|
||||
const textColors: Record<string, string> = {
|
||||
cyan: 'text-cyan-400',
|
||||
orange: 'text-orange-400',
|
||||
yellow: 'text-yellow-400',
|
||||
pink: 'text-pink-400',
|
||||
blue: 'text-blue-400',
|
||||
};
|
||||
const bgColors: Record<string, string> = {
|
||||
cyan: 'bg-cyan-500/10',
|
||||
orange: 'bg-orange-500/10',
|
||||
yellow: 'bg-yellow-500/10',
|
||||
pink: 'bg-pink-500/10',
|
||||
blue: 'bg-blue-500/10',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ y: -30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
|
||||
>
|
||||
{/* Header Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={12} className="text-cyan-500" />
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA FILTERS</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="text-[9px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-sm">
|
||||
{activeCount} ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
{isMinimized ? <SlidersHorizontal size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</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 flex flex-col gap-2 p-3 pt-2 max-h-[400px]"
|
||||
>
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-[9px] text-red-400 hover:text-red-300 tracking-widest self-end mb-1"
|
||||
>
|
||||
CLEAR ALL FILTERS
|
||||
</button>
|
||||
)}
|
||||
|
||||
{sections.map(section => {
|
||||
const count = getCountForCategory(section.key);
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
className={`border rounded-lg transition-all cursor-pointer group ${borderColors[section.color] || 'border-gray-800'} hover:bg-black/30`}
|
||||
onClick={() => setOpenModal(section.key)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-2.5 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{section.icon}
|
||||
<span className="text-[9px] text-gray-400 tracking-widest group-hover:text-gray-200 transition-colors">{section.title}</span>
|
||||
{count > 0 && (
|
||||
<span className={`text-[8px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SlidersHorizontal size={10} className="text-gray-600 group-hover:text-gray-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Render active modal */}
|
||||
<AnimatePresence>
|
||||
{openModal && modalConfigs[openModal] && (
|
||||
<AdvancedFilterModal
|
||||
key={openModal}
|
||||
title={modalConfigs[openModal].title}
|
||||
icon={modalConfigs[openModal].icon}
|
||||
accentColor={modalConfigs[openModal].accentColor}
|
||||
accentColorName={modalConfigs[openModal].accentColorName}
|
||||
fields={modalConfigs[openModal].fields}
|
||||
activeFilters={activeFilters}
|
||||
onApply={(filters) => handleModalApply(openModal, filters)}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { Search, Crosshair, Plane, Shield, Star, Ship, X, Database } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { trackedOperators } from '../lib/trackedData';
|
||||
|
||||
interface FindLocateBarProps {
|
||||
data: any;
|
||||
onLocate: (lat: number, lng: number, entityId: string, entityType: string) => void;
|
||||
onFilter?: (filterType: string, filterValue: string) => void;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
label: string;
|
||||
sublabel: string;
|
||||
category: string;
|
||||
categoryColor: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
entityType: string;
|
||||
}
|
||||
|
||||
export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBarProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
// Build searchable index from all data
|
||||
const allEntities = useMemo(() => {
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// Commercial flights
|
||||
for (const f of data?.commercial_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
results.push({
|
||||
id: `flight-${uid}`,
|
||||
label: f.callsign || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · ${f.airline_code || 'Commercial'}`,
|
||||
category: "COMMERCIAL",
|
||||
categoryColor: "text-cyan-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "flight",
|
||||
});
|
||||
}
|
||||
|
||||
// Private flights
|
||||
for (const f of [...(data?.private_flights || []), ...(data?.private_jets || [])]) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
const type = f.type === 'private_jet' ? 'private_jet' : 'private_flight';
|
||||
results.push({
|
||||
id: `${type === 'private_jet' ? 'private-jet' : 'private-flight'}-${uid}`,
|
||||
label: f.callsign || f.registration || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · Private`,
|
||||
category: "PRIVATE",
|
||||
categoryColor: "text-orange-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: type,
|
||||
});
|
||||
}
|
||||
|
||||
// Military flights
|
||||
for (const f of data?.military_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
results.push({
|
||||
id: `mil-flight-${uid}`,
|
||||
label: f.callsign || uid,
|
||||
sublabel: `${f.model || 'Unknown'} · ${f.military_type || 'Military'}`,
|
||||
category: "MILITARY",
|
||||
categoryColor: "text-yellow-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "military_flight",
|
||||
});
|
||||
}
|
||||
|
||||
// Tracked flights
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
const uid = f.icao24 || f.registration || f.callsign || '';
|
||||
const operator = f.alert_operator || 'Unknown Operator';
|
||||
const category = f.alert_category || 'Tracked';
|
||||
const type = f.alert_type || f.model || 'Unknown';
|
||||
results.push({
|
||||
id: `tracked-${uid}`,
|
||||
label: operator,
|
||||
sublabel: `${category} · ${type} (${f.registration || uid})`,
|
||||
category: "TRACKED",
|
||||
categoryColor: "text-pink-400",
|
||||
lat: f.lat,
|
||||
lng: f.lng,
|
||||
entityType: "tracked_flight",
|
||||
});
|
||||
}
|
||||
|
||||
// Ships
|
||||
for (const s of data?.ships || []) {
|
||||
results.push({
|
||||
id: `ship-${s.mmsi || s.name || ''}`,
|
||||
label: s.name || "UNKNOWN",
|
||||
sublabel: `${s.type || 'Vessel'} · ${s.destination || 'Unknown dest'}`,
|
||||
category: "MARITIME",
|
||||
categoryColor: "text-blue-400",
|
||||
lat: s.lat,
|
||||
lng: s.lng,
|
||||
entityType: "ship",
|
||||
});
|
||||
}
|
||||
|
||||
// Database Records - Tracked Operators
|
||||
for (const op of trackedOperators) {
|
||||
results.push({
|
||||
id: `tracked-db-${op}`,
|
||||
label: op,
|
||||
sublabel: `Database Record · Operator`,
|
||||
category: "DATABASE",
|
||||
categoryColor: "text-purple-400",
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
entityType: "database_operator",
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [data]);
|
||||
|
||||
// Filter results based on query
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.toLowerCase();
|
||||
return allEntities
|
||||
.filter(e => {
|
||||
const searchable = `${e.label} ${e.sublabel} ${e.id}`.toLowerCase();
|
||||
return searchable.includes(q);
|
||||
})
|
||||
.slice(0, 12);
|
||||
}, [query, allEntities]);
|
||||
|
||||
const handleSelect = (result: SearchResult) => {
|
||||
if (result.entityType === "database_operator") {
|
||||
if (onFilter) onFilter("tracked_owner", result.label);
|
||||
} else {
|
||||
onLocate(result.lat, result.lng, result.id, result.entityType);
|
||||
}
|
||||
setQuery("");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, React.ReactNode> = {
|
||||
COMMERCIAL: <Plane size={10} className="text-cyan-400" />,
|
||||
PRIVATE: <Plane size={10} className="text-orange-400" />,
|
||||
MILITARY: <Shield size={10} className="text-yellow-400" />,
|
||||
TRACKED: <Star size={10} className="text-pink-400" />,
|
||||
MARITIME: <Ship size={10} className="text-blue-400" />,
|
||||
DATABASE: <Database size={10} className="text-purple-400" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full pointer-events-auto">
|
||||
<div className="flex items-center gap-2 bg-black/40 backdrop-blur-md border border-gray-800 rounded-lg px-3 py-2 focus-within:border-cyan-500/40 transition-colors">
|
||||
<Search size={12} className="text-gray-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder="Find aircraft or vessel..."
|
||||
className="flex-1 bg-transparent text-[10px] text-gray-300 font-mono tracking-wider outline-none placeholder:text-gray-600"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={() => { setQuery(""); setIsOpen(false); }} className="text-gray-600 hover:text-white transition-colors">
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
<Crosshair size={12} className="text-gray-600 flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && filtered.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg overflow-hidden z-50 shadow-[0_8px_30px_rgba(0,0,0,0.6)]"
|
||||
>
|
||||
<div className="max-h-[300px] overflow-y-auto styled-scrollbar">
|
||||
{filtered.map((r, idx) => (
|
||||
<button
|
||||
key={`${r.id}-${idx}`}
|
||||
onClick={() => handleSelect(r)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-cyan-950/30 transition-colors text-left border-b border-gray-800/50 last:border-0 group"
|
||||
>
|
||||
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded bg-gray-900 border border-gray-800 group-hover:border-cyan-800">
|
||||
{categoryIcons[r.category]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] text-gray-200 font-mono tracking-wide truncate">{r.label}</div>
|
||||
<div className="text-[8px] text-gray-500 font-mono truncate">{r.sublabel}</div>
|
||||
</div>
|
||||
<span className={`text-[7px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}>
|
||||
{r.category}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-gray-800 bg-black/50 text-[8px] text-gray-600 font-mono tracking-widest">
|
||||
{filtered.length} RESULT{filtered.length !== 1 ? 'S' : ''} — CLICK TO LOCATE
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{isOpen && query.trim() && filtered.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg z-50 p-4 text-center"
|
||||
>
|
||||
<div className="text-[9px] text-gray-600 font-mono tracking-widest">NO MATCHING ASSETS</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap, Tooltip, CircleMarker, useMapEvents } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Fix standard leaflet icon path issues in React
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
// Create custom icons dynamically for the layers
|
||||
const createDivIcon = (svg: string, size = 16, rotate = 0) => {
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `<div style="transform: rotate(${rotate}deg); width: ${size}px; height: ${size}px;">${svg}</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
});
|
||||
};
|
||||
|
||||
const svgPlaneCyan = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00d4ff"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`;
|
||||
const svgPlaneOrange = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffaa00"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`;
|
||||
const svgPlaneRed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff3333"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`;
|
||||
const svgShip = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#888888"><path d="M12 22V8" /><path d="M5 12H19" /><path d="M9 22H15" /><circle cx="12" cy="5" r="3" /><path d="M12 22C8 22 4 19 4 15V13M12 22C16 22 20 19 20 15V13" /></svg>`;
|
||||
const svgThreat = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffff00" stroke="#ff0000" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>`;
|
||||
const svgTriangleYellow = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffaa00" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
const svgTriangleRed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff0000" stroke="#fff" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
|
||||
// Helper component to center map when Find Locater is used
|
||||
function MapCenterControl({ location }: { location: { lat: number, lng: number } | null }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (location) {
|
||||
map.flyTo([location.lat, location.lng], 8, { duration: 1.5 });
|
||||
}
|
||||
}, [location, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Eavesdrop mode controller
|
||||
function ClickHandler({ isEavesdropping, onEavesdropClick }: any) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!isEavesdropping) return;
|
||||
const cb = (e: any) => onEavesdropClick({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
map.on('click', cb);
|
||||
return () => { map.off('click', cb); };
|
||||
}, [isEavesdropping, map, onEavesdropClick]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map state tracker for LOD
|
||||
function MapStateTracker({ onStateChange }: { onStateChange: (zoom: number, bounds: L.LatLngBounds) => void }) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => onStateChange(map.getZoom(), map.getBounds()),
|
||||
zoomend: () => onStateChange(map.getZoom(), map.getBounds()),
|
||||
});
|
||||
useEffect(() => {
|
||||
onStateChange(map.getZoom(), map.getBounds());
|
||||
}, [map, onStateChange]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LeafletViewer({ data, activeLayers, activeFilters, effects, onEntityClick, selectedEntity, flyToLocation, isEavesdropping, onEavesdropClick, onCameraMove }: any) {
|
||||
const [zoom, setZoom] = useState(3);
|
||||
const [bounds, setBounds] = useState<L.LatLngBounds | null>(null);
|
||||
|
||||
const handleMapState = (z: number, b: L.LatLngBounds) => {
|
||||
setZoom(z);
|
||||
setBounds(b);
|
||||
};
|
||||
|
||||
const isVisible = (lat: number, lng: number) => {
|
||||
if (!bounds) return true;
|
||||
return bounds.pad(0.2).contains([lat, lng]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: "100vw", height: "100vh", position: "fixed", top: 0, left: 0, zIndex: 0, background: "black" }}>
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={3}
|
||||
style={{ width: "100%", height: "100%", background: "#06080a" }} // Extremely dark ocean base
|
||||
zoomControl={false}
|
||||
minZoom={2}
|
||||
maxZoom={12}
|
||||
>
|
||||
<MapStateTracker onStateChange={handleMapState} />
|
||||
<MapCenterControl location={flyToLocation} />
|
||||
<ClickHandler isEavesdropping={isEavesdropping} onEavesdropClick={onEavesdropClick} />
|
||||
|
||||
{/* Dark Mode Satellite Stamen / CartoDB Voyager basemap substitute */}
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
maxZoom={19}
|
||||
/>
|
||||
|
||||
{/* --- COMMERCIAL FLIGHTS --- */}
|
||||
{activeLayers.flights && data?.commercial_flights?.map((f: any, idx: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`comm-${idx}`}
|
||||
center={[f.lat, f.lng]}
|
||||
radius={2}
|
||||
pathOptions={{ color: '#00d4ff', fillColor: '#00d4ff', fillOpacity: 0.8, weight: 1, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgPlaneCyan, 18, f.true_track || f.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`comm-${idx}`}
|
||||
position={[f.lat, f.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||
<div className="text-cyan-400 font-bold bg-black px-1 text-xs border border-cyan-500/50">{f.callsign || f.icao24}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- PRIVATE FLIGHTS --- */}
|
||||
{activeLayers.private && data?.private_flights?.map((f: any, idx: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`priv-${idx}`}
|
||||
center={[f.lat, f.lng]}
|
||||
radius={2}
|
||||
pathOptions={{ color: '#ffaa00', fillColor: '#ffaa00', fillOpacity: 0.8, weight: 1, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'private_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgPlaneOrange, 18, f.true_track || f.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`priv-${idx}`}
|
||||
position={[f.lat, f.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'private_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||
<div className="text-orange-400 font-bold bg-black px-1 text-xs border border-orange-500/50">{f.callsign || f.icao24}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- MILITARY FLIGHTS --- */}
|
||||
{activeLayers.military && data?.military_flights?.map((f: any, idx: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`mil-${idx}`}
|
||||
center={[f.lat, f.lng]}
|
||||
radius={3}
|
||||
pathOptions={{ color: '#ff3333', fillColor: '#ff3333', fillOpacity: 0.9, weight: 1, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'military_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgPlaneRed, 20, f.true_track || f.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`mil-${idx}`}
|
||||
position={[f.lat, f.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'military_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||
<div className="text-red-500 font-bold bg-black px-1 text-xs border border-red-500/50">{f.callsign || f.icao24}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- SHIPS --- */}
|
||||
{(activeLayers.ships_important || activeLayers.ships_civilian || activeLayers.ships_passenger) && data?.ships?.map((s: any, idx: number) => {
|
||||
if (s.lat == null || s.lng == null) return null;
|
||||
if (zoom >= 6 && !isVisible(s.lat, s.lng)) return null;
|
||||
|
||||
if (zoom < 6) {
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`ship-${idx}`}
|
||||
center={[s.lat, s.lng]}
|
||||
radius={1.5}
|
||||
pathOptions={{ color: '#888888', fillColor: '#888888', fillOpacity: 0.6, weight: 0.5, stroke: false }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'ship', id: idx, name: s.name }) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = createDivIcon(svgShip, 12, s.heading || 0);
|
||||
return (
|
||||
<Marker
|
||||
key={`ship-${idx}`}
|
||||
position={[s.lat, s.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'ship', id: idx, name: s.name }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -5]} opacity={0.8}>
|
||||
<div className="text-gray-300 font-bold bg-black px-1 text-[10px] border border-gray-600/50">{s.name}</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- GDELT GLOBAL INCIDENTS --- */}
|
||||
{activeLayers.global_incidents && data?.gdelt?.map((incident: any, idx: number) => {
|
||||
const geom = incident.geometry;
|
||||
if (!geom || geom.type !== 'Point' || !geom.coordinates) return null;
|
||||
const lng = geom.coordinates[0];
|
||||
const lat = geom.coordinates[1];
|
||||
if (!isVisible(lat, lng)) return null;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`gdelt-${idx}`}
|
||||
center={[geom.coordinates[1], geom.coordinates[0]]}
|
||||
radius={8}
|
||||
pathOptions={{ color: '#ff0000', fillColor: '#ff8c00', fillOpacity: 0.6, weight: 2 }}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'gdelt', id: idx }) }}
|
||||
>
|
||||
<Tooltip>
|
||||
<div className="text-orange-500 text-xs bg-black p-1 max-w-[200px] whitespace-normal">
|
||||
{incident.title}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- LIVEUAMAP INCIDENTS --- */}
|
||||
{activeLayers.global_incidents && data?.liveuamap?.map((incident: any, idx: number) => {
|
||||
if (incident.lat == null || incident.lng == null) return null;
|
||||
if (!isVisible(incident.lat, incident.lng)) return null;
|
||||
const isViolent = /bomb|missil|strike|attack|kill|destroy|fire|shoot|expl|raid/i.test(incident.title || "");
|
||||
const icon = createDivIcon(isViolent ? svgTriangleRed : svgTriangleYellow, 18);
|
||||
return (
|
||||
<Marker
|
||||
key={`liveua-${idx}`}
|
||||
position={[incident.lat, incident.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'liveuamap', id: incident.id, title: incident.title }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.95}>
|
||||
<div className="text-white font-bold bg-black p-1 text-[11px] border border-gray-600 max-w-[200px] whitespace-normal">
|
||||
<span className={isViolent ? "text-red-500" : "text-yellow-500"}>[LIVEUA]</span> {incident.title}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* --- RSS THREAT ALERTS --- */}
|
||||
{activeLayers.global_incidents && data?.news?.filter((n: any) => n.coordinates)?.map((n: any, idx: number) => {
|
||||
if (n.coordinates.lat == null || n.coordinates.lng == null) return null;
|
||||
if (!isVisible(n.coordinates.lat, n.coordinates.lng)) return null;
|
||||
const icon = createDivIcon(svgThreat, 24);
|
||||
return (
|
||||
<Marker
|
||||
key={`threat-${idx}`}
|
||||
position={[n.coordinates.lat, n.coordinates.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: () => onEntityClick?.({ type: 'news', id: idx }) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]} opacity={1.0} permanent={true} className="bg-transparent border-0 shadow-none">
|
||||
<div className="text-red-500 font-bold bg-black/80 px-2 py-1 text-[10px] border border-red-500/50 backdrop-blur" style={{ textShadow: "0px 0px 4px #000" }}>
|
||||
!! LVL {n.threat_level} !!<br />
|
||||
<span className="text-yellow-400 font-normal">{n.title.substring(0, 30)}...</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
// ─── Inline SVG legend icons (small, crisp, no external deps) ───
|
||||
const plane = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`;
|
||||
|
||||
const airliner = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 2C11.2 2 10.5 2.8 10.5 3.5V8.5L3 13V15L10.5 12.5V18L8 19.5V21L12 19.5L16 21V19.5L13.5 18V12.5L21 15V13L13.5 8.5V3.5C13.5 2.8 12.8 2 12 2Z M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5"/><circle cx="7" cy="12.5" r="1.2" fill="${fill}" stroke="black" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="${fill}" stroke="black" stroke-width="0.5"/></svg>`;
|
||||
|
||||
const turboprop = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z"/></svg>`;
|
||||
|
||||
const bizjet = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z"/></svg>`;
|
||||
|
||||
const heli = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="${fill}" stroke-dasharray="2 2" stroke-width="1"/></svg>`;
|
||||
|
||||
const ship = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
|
||||
const triangle = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||
|
||||
const circle = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
|
||||
const dot = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5" fill="${fill}" stroke="#000" stroke-width="1"/></svg>`;
|
||||
|
||||
function IconImg({ svg }: { svg: string }) {
|
||||
return <img src={`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`} alt="" className="w-4 h-4 flex-shrink-0" draggable={false} />;
|
||||
}
|
||||
|
||||
// ─── Legend data ───
|
||||
|
||||
interface LegendItem {
|
||||
svg: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface LegendCategory {
|
||||
name: string;
|
||||
color: string;
|
||||
items: LegendItem[];
|
||||
}
|
||||
|
||||
const sat = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${fill}" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="8" fill="none" stroke="${fill}" stroke-dasharray="3 3" stroke-width="0.8"/></svg>`;
|
||||
|
||||
const square = (fill: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="${fill}" stroke="#000" stroke-width="1" opacity="0.6" rx="2"/></svg>`;
|
||||
|
||||
const clusterCircle = (fill: string, stroke: string, size = 16) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="${fill}" stroke="${stroke}" stroke-width="2" opacity="0.8"/><text x="12" y="15" text-anchor="middle" fill="white" font-size="8" font-family="monospace" font-weight="bold">5</text></svg>`;
|
||||
|
||||
const LEGEND: LegendCategory[] = [
|
||||
{
|
||||
name: "COMMERCIAL AVIATION",
|
||||
color: "text-cyan-400 border-cyan-500/30",
|
||||
items: [
|
||||
{ svg: airliner("cyan"), label: "Airliner (swept wings)" },
|
||||
{ svg: turboprop("cyan"), label: "Turboprop (straight wings)" },
|
||||
{ svg: heli("cyan"), label: "Helicopter (rotor disc)" },
|
||||
{ svg: airliner("#555"), label: "Grounded / Parked (grey)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "PRIVATE AVIATION",
|
||||
color: "text-orange-400 border-orange-500/30",
|
||||
items: [
|
||||
{ svg: airliner("#FF8C00"), label: "Private Flight — Airliner" },
|
||||
{ svg: turboprop("#FF8C00"), label: "Private Flight — Turboprop" },
|
||||
{ svg: heli("#FF8C00"), label: "Private Flight — Helicopter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "PRIVATE JETS",
|
||||
color: "text-purple-400 border-purple-500/30",
|
||||
items: [
|
||||
{ svg: bizjet("#9B59B6"), label: "Private Jet — Bizjet" },
|
||||
{ svg: airliner("#9B59B6"), label: "Private Jet — Airliner" },
|
||||
{ svg: turboprop("#9B59B6"), label: "Private Jet — Turboprop" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MILITARY AVIATION",
|
||||
color: "text-yellow-400 border-yellow-500/30",
|
||||
items: [
|
||||
{ svg: airliner("yellow"), label: "Military — Standard" },
|
||||
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
||||
{ svg: heli("yellow"), label: "Military — Helicopter" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="orange" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.6"/><circle cx="12" cy="12" r="2" fill="orange"/></svg>`, label: "UAV Operational Range (dashed circle)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "TRACKED AIRCRAFT (ALERT)",
|
||||
color: "text-pink-400 border-pink-500/30",
|
||||
items: [
|
||||
{ svg: airliner("#FF1493"), label: "Alert — Low Priority (pink)" },
|
||||
{ svg: airliner("#FF2020"), label: "Alert — High Priority (red)" },
|
||||
{ svg: airliner("#1A3A8A"), label: "Alert — Government (navy)" },
|
||||
{ svg: airliner("white"), label: "Alert — General (white)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SATELLITES",
|
||||
color: "text-sky-400 border-sky-500/30",
|
||||
items: [
|
||||
{ svg: sat("#ff3333"), label: "Military Recon / SAR (red)" },
|
||||
{ svg: sat("#00e5ff"), label: "Synthetic Aperture Radar (cyan)" },
|
||||
{ svg: sat("#ffffff"), label: "Signals Intelligence / ELINT (white)" },
|
||||
{ svg: sat("#4488ff"), label: "Navigation — GPS / GLONASS / BeiDou (blue)" },
|
||||
{ svg: sat("#ff00ff"), label: "Early Warning — Missile Detection (magenta)" },
|
||||
{ svg: sat("#44ff44"), label: "Commercial Imaging (green)" },
|
||||
{ svg: sat("#ffdd00"), label: "Space Station — ISS / Tiangong (gold)" },
|
||||
{ svg: sat("#aaaaaa"), label: "Unclassified / Other (grey)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MARITIME",
|
||||
color: "text-blue-400 border-blue-500/30",
|
||||
items: [
|
||||
{ svg: ship("gray"), label: "Civilian / Unknown Vessel" },
|
||||
{ svg: ship("yellow"), label: "Tanker" },
|
||||
{ svg: ship("#ff2222"), label: "Military Vessel" },
|
||||
{ svg: ship("#3b82f6"), label: "Cargo Ship" },
|
||||
{ svg: ship("white"), label: "Cruise / Passenger" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>`, label: "Aircraft Carrier" },
|
||||
{ svg: clusterCircle("#3b82f6", "#1d4ed8"), label: "Ship Cluster (count inside)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "GEOPHYSICAL",
|
||||
color: "text-orange-400 border-orange-500/30",
|
||||
items: [
|
||||
{ svg: circle("#ff6600"), label: "Earthquake (size = magnitude)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "INCIDENTS & INTELLIGENCE",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
items: [
|
||||
{ svg: triangle("#ffaa00"), label: "GDELT / LiveUA event (yellow)" },
|
||||
{ svg: triangle("#ff0000"), label: "Violent / Kinetic event (red)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#ffff00" stroke="#ff0000" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>`, label: "Threat Alert (news cluster)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "NEWS & OSINT",
|
||||
color: "text-cyan-400 border-cyan-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12" viewBox="0 0 40 24"><rect x="1" y="1" width="38" height="22" rx="3" fill="#111" stroke="cyan" stroke-width="1"/><text x="6" y="10" fill="red" font-size="6" font-family="monospace">!! ALERT</text><text x="6" y="17" fill="white" font-size="4" font-family="monospace">News Headline</text></svg>`, label: "Geolocated news alert box" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "GPS JAMMING / INTERFERENCE",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
items: [
|
||||
{ svg: square("#ff0040"), label: "High severity (>75% aircraft degraded)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.35" rx="2"/></svg>`, label: "Medium severity (50-75% degraded)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.2" rx="2"/></svg>`, label: "Low severity (25-50% degraded)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SURVEILLANCE / CCTV",
|
||||
color: "text-green-400 border-green-500/30",
|
||||
items: [
|
||||
{ svg: dot("#22c55e"), label: "Individual CCTV camera (green dot)" },
|
||||
{ svg: clusterCircle("#22c55e", "#16a34a"), label: "Camera cluster (count inside)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>`, label: "CCTV icon (detail view)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "OVERLAYS",
|
||||
color: "text-gray-400 border-gray-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect width="24" height="24" fill="#0a0e1a" opacity="0.4"/><circle cx="12" cy="12" r="4" fill="#ffd700"/></svg>`, label: "Day / Night terminator" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><line x1="4" y1="4" x2="20" y2="4" stroke="red" stroke-width="2"/><line x1="4" y1="8" x2="20" y2="8" stroke="#ff6600" stroke-width="2"/></svg>`, label: "Ukraine frontline" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (name: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Legend Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] max-h-[80vh] bg-gray-950/95 backdrop-blur-xl border border-cyan-900/50 rounded-xl z-[9999] flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.8)]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-800/80 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="cyan" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M12 3v12" />
|
||||
<path d="m8 11 4 4 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">MAP LEGEND</h2>
|
||||
<span className="text-[9px] text-gray-500 font-mono tracking-widest">ICON REFERENCE KEY</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend Content */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
|
||||
{LEGEND.map((cat) => {
|
||||
const isCollapsed = collapsed.has(cat.name);
|
||||
return (
|
||||
<div key={cat.name} className="rounded-lg border border-gray-800/60 overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggle(cat.name)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
|
||||
>
|
||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${cat.color}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
{isCollapsed ? <ChevronDown size={12} className="text-gray-500" /> : <ChevronUp size={12} className="text-gray-500" />}
|
||||
</button>
|
||||
|
||||
{/* Items */}
|
||||
<AnimatePresence>
|
||||
{!isCollapsed && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="border-t border-gray-800/40"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-0">
|
||||
{cat.items.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 px-4 py-1.5 hover:bg-gray-900/30 transition-colors">
|
||||
<IconImg svg={item.svg} />
|
||||
<span className="text-[11px] text-gray-300 font-mono">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-gray-800/80 flex-shrink-0">
|
||||
<div className="text-[9px] text-gray-600 font-mono text-center tracking-wider">
|
||||
{LEGEND.reduce((sum, c) => sum + c.items.length, 0)} ICON DEFINITIONS ACROSS {LEGEND.length} CATEGORIES
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default MapLegend;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, Droplet, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
|
||||
const stocks = data?.stocks || {};
|
||||
const oil = data?.oil || {};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
|
||||
>
|
||||
{/* Header Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</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 flex flex-col gap-4 p-4 pt-3 max-h-[400px]"
|
||||
>
|
||||
<div className="border-b border-gray-800 pb-3">
|
||||
<h2 className="text-xs font-bold tracking-widest text-cyan-400 flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="text-cyan-500" size={14} /> DEFENSE SEC TICKERS
|
||||
</h2>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{Object.entries(stocks).map(([ticker, info]: [string, any]) => (
|
||||
<div key={ticker} className="flex items-center justify-between border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm relative group overflow-hidden">
|
||||
<span className="font-bold text-cyan-300 z-10 text-[10px]">[{ticker}]</span>
|
||||
<div className="flex items-center gap-3 text-right z-10">
|
||||
<span className="text-gray-200 font-bold text-xs">${info.price.toFixed(2)}</span>
|
||||
<span className={`flex items-center gap-0.5 w-12 justify-end text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
|
||||
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
||||
{Math.abs(info.change_percent).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xs font-bold tracking-widest text-cyan-400 flex items-center gap-2 mb-2">
|
||||
<Droplet className="text-cyan-500" size={14} /> COMMODITY INDEX
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{Object.entries(oil).map(([name, info]: [string, any]) => (
|
||||
<div key={name} className="flex flex-col border border-cyan-500/10 bg-cyan-950/10 p-1.5 rounded-sm justify-between">
|
||||
<span className="font-bold text-cyan-500 text-[9px] uppercase mb-0.5">{name}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-200 font-bold text-[11px]">${info.price.toFixed(2)}</span>
|
||||
<span className={`flex items-center gap-0.5 text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
|
||||
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
||||
{Math.abs(info.change_percent).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default MarketsPanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { RadioReceiver, Activity, Play, Square, FastForward, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesdropping, eavesdropLocation, cameraCenter }: { data: any, isEavesdropping?: boolean, setIsEavesdropping?: (val: boolean) => void, eavesdropLocation?: { lat: number, lng: number } | null, cameraCenter?: { lat: number, lng: number } | null }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [feeds, setFeeds] = useState<any[]>([]);
|
||||
const [activeFeed, setActiveFeed] = useState<any | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const scanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Fetch the top feeds on mount
|
||||
useEffect(() => {
|
||||
const fetchFeeds = async () => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/radio/top");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setFeeds(json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch radio feeds", e);
|
||||
}
|
||||
};
|
||||
fetchFeeds();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchFeeds, 300000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Handle Eavesdrop Map Clicks
|
||||
useEffect(() => {
|
||||
if (eavesdropLocation && isEavesdropping) {
|
||||
const fetchNearest = async () => {
|
||||
try {
|
||||
// Show a temporary state
|
||||
setFeeds(prev => [{
|
||||
id: 'scanning-nearest',
|
||||
name: 'TRIANGULATING SIGNAL...',
|
||||
location: `LAT:${eavesdropLocation.lat.toFixed(2)} LNG:${eavesdropLocation.lng.toFixed(2)}`,
|
||||
listeners: 0,
|
||||
category: 'SIGINT'
|
||||
}, ...prev]);
|
||||
|
||||
const res = await fetch(`http://localhost:8000/api/radio/nearest?lat=${eavesdropLocation.lat}&lng=${eavesdropLocation.lng}`);
|
||||
if (res.ok) {
|
||||
const system = await res.json();
|
||||
if (system && system.shortName) {
|
||||
// Valid OpenMHZ system found! Fetch recent calls
|
||||
const callRes = await fetch(`http://localhost:8000/api/radio/openmhz/calls/${system.shortName}`);
|
||||
if (callRes.ok) {
|
||||
const calls = await callRes.json();
|
||||
if (calls && calls.length > 0) {
|
||||
// Found bursts!
|
||||
const latest = calls[0];
|
||||
const openMhzFeed = {
|
||||
id: `openmhz-${system.shortName}-${latest.id}`,
|
||||
name: `${system.name} (TG:${latest.talkgroupNum})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
listeners: system.clientCount || 0,
|
||||
category: 'TRUNKED INTERCEPT',
|
||||
stream_url: latest.url
|
||||
};
|
||||
|
||||
// Remove the triangulating placeholder and add the new intercept
|
||||
setFeeds(prev => {
|
||||
const clean = prev.filter(f => f.id !== 'scanning-nearest');
|
||||
// Avoid duplicates if we clicked the same place twice
|
||||
if (clean.find(f => f.id === openMhzFeed.id)) return clean;
|
||||
return [openMhzFeed, ...clean];
|
||||
});
|
||||
// Auto-play the intercept
|
||||
playFeed(openMhzFeed);
|
||||
} else {
|
||||
// Provide failure feedback
|
||||
setFeeds(prev => {
|
||||
const clean = prev.filter(f => f.id !== 'scanning-nearest');
|
||||
return [{
|
||||
id: `failed-${Date.now()}`,
|
||||
name: `NO RECENT COMMS (${system.shortName})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
category: 'DEAD AIR',
|
||||
listeners: 0
|
||||
}, ...clean];
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Provide failure feedback
|
||||
setFeeds(prev => {
|
||||
const clean = prev.filter(f => f.id !== 'scanning-nearest');
|
||||
return [{
|
||||
id: `failed-${Date.now()}`,
|
||||
name: 'NO LOCAL REPEATERS FOUND',
|
||||
location: 'UNKNOWN',
|
||||
category: 'ENCRYPTED / VOID',
|
||||
listeners: 0
|
||||
}, ...clean];
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Nearest system lookup failed", e);
|
||||
}
|
||||
};
|
||||
fetchNearest();
|
||||
}
|
||||
}, [eavesdropLocation]);
|
||||
|
||||
const playFeed = (feed: any) => {
|
||||
if (isScanning && scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
setIsScanning(false);
|
||||
}
|
||||
setActiveFeed(feed);
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const stopFeed = () => {
|
||||
if (isScanning && scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
setIsScanning(false);
|
||||
}
|
||||
setActiveFeed(null);
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
// Handle Audio Element Play/Stop
|
||||
useEffect(() => {
|
||||
if (activeFeed && isPlaying) {
|
||||
if (!audioRef.current) {
|
||||
const audio = new Audio(activeFeed.stream_url);
|
||||
audioRef.current = audio;
|
||||
} else {
|
||||
audioRef.current.src = activeFeed.stream_url;
|
||||
}
|
||||
audioRef.current.volume = volume;
|
||||
audioRef.current.play().catch(e => console.log("Audio play blocked", e));
|
||||
} else {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = "";
|
||||
}
|
||||
}
|
||||
}, [activeFeed, isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = "";
|
||||
}
|
||||
if (scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleScan = () => {
|
||||
if (isScanning) {
|
||||
setIsScanning(false);
|
||||
if (scanTimeoutRef.current) clearTimeout(scanTimeoutRef.current);
|
||||
stopFeed();
|
||||
} else {
|
||||
setIsScanning(true);
|
||||
scanNextFeed();
|
||||
}
|
||||
};
|
||||
|
||||
const scanNextFeed = async () => {
|
||||
if (!isScanning) return;
|
||||
|
||||
// Try localized scan first if we have a camera center or eavesdrop location
|
||||
const scanLoc = eavesdropLocation || cameraCenter;
|
||||
|
||||
let localFeedFound = false;
|
||||
|
||||
if (scanLoc) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/radio/nearest-list?lat=${scanLoc.lat}&lng=${scanLoc.lng}&limit=3`);
|
||||
if (res.ok) {
|
||||
const systems = await res.json();
|
||||
|
||||
// Try to find a system with an active unplayed burst
|
||||
for (const system of systems) {
|
||||
if (system && system.shortName) {
|
||||
const callRes = await fetch(`http://localhost:8000/api/radio/openmhz/calls/${system.shortName}`);
|
||||
if (callRes.ok) {
|
||||
const calls = await callRes.json();
|
||||
if (calls && calls.length > 0) {
|
||||
// Normally we would track played calls. For now just pick random recent one.
|
||||
const randomCall = calls[Math.floor(Math.random() * Math.min(calls.length, 3))];
|
||||
const openMhzFeed = {
|
||||
id: `openmhz-${system.shortName}-${randomCall.id}`,
|
||||
name: `${system.name} (TG:${randomCall.talkgroupNum})`,
|
||||
location: `${system.city}, ${system.state}`,
|
||||
listeners: system.clientCount || 0,
|
||||
category: 'TRUNKED INTERCEPT',
|
||||
stream_url: randomCall.url
|
||||
};
|
||||
|
||||
// Replace feeds list visually with this active sector
|
||||
setFeeds(prev => {
|
||||
if (prev.find(f => f.id === openMhzFeed.id)) return prev;
|
||||
return [openMhzFeed, ...prev].slice(0, 10);
|
||||
});
|
||||
setActiveFeed(openMhzFeed);
|
||||
setIsPlaying(true);
|
||||
localFeedFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Auto scan local query failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localFeedFound && feeds.length > 0) {
|
||||
// Fallback: Pick a random hot feed or cycle them
|
||||
const randomIdx = Math.floor(Math.random() * Math.min(feeds.length, 10)); // Pick from top 10
|
||||
setActiveFeed(feeds[randomIdx]);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
|
||||
// Scan for 15 seconds then switch
|
||||
scanTimeoutRef.current = setTimeout(() => {
|
||||
if (isScanning) scanNextFeed();
|
||||
}, 15000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="w-full flex flex-col bg-black/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] 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"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-cyan-400">
|
||||
<RadioReceiver size={14} className={isPlaying ? "animate-pulse" : ""} />
|
||||
<span className="text-[10px] font-mono tracking-widest font-semibold">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">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Audio Player Controls */}
|
||||
<div className="p-4 border-b border-cyan-900/40 bg-black/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">
|
||||
{activeFeed ? activeFeed.name : "NO SIGNAL"}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-500 font-mono">
|
||||
{activeFeed ? `LOCATION: ${activeFeed.location.toUpperCase()}` : "AWAITING TUNING..."}
|
||||
</span>
|
||||
</div>
|
||||
{activeFeed && (
|
||||
<div className="flex items-center gap-1 bg-red-950/40 border border-red-900/50 px-2 py-0.5 rounded text-[9px] text-red-400 font-mono">
|
||||
<Activity size={10} className="animate-pulse" />
|
||||
LIVE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={activeFeed ? stopFeed : () => feeds.length > 0 && playFeed(feeds[0])}
|
||||
className={`p-2 rounded-full border ${activeFeed ? 'border-red-500/50 text-red-500 hover:bg-red-950/50' : 'border-cyan-700 text-cyan-500 hover:bg-cyan-900/50'} transition-colors`}
|
||||
>
|
||||
{activeFeed ? <Square size={14} /> : <Play size={14} className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleScan}
|
||||
className={`px-3 py-1.5 rounded text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isScanning ? 'bg-cyan-900/60 border-cyan-400 text-cyan-300' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
||||
>
|
||||
<FastForward size={12} />
|
||||
{isScanning ? 'SCANNING...' : 'AUTO SCAN'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsEavesdropping && setIsEavesdropping(!isEavesdropping)}
|
||||
className={`px-3 py-1.5 rounded text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isEavesdropping ? 'bg-red-900/60 border-red-500 text-red-300 animate-pulse' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
||||
title="Click on the globe to intercept local signals"
|
||||
>
|
||||
EAVESDROP
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0" max="1" step="0.05"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-20 accent-cyan-500"
|
||||
title="Volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fake Waveform Visualizer */}
|
||||
<div className="mt-4 flex items-end gap-[2px] h-8 opacity-70">
|
||||
{Array.from({ length: 48 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={`w-1 rounded-t-sm ${isPlaying ? 'bg-cyan-500' : 'bg-cyan-900/50'}`}
|
||||
animate={{
|
||||
height: isPlaying
|
||||
? ['10%', `${Math.random() * 80 + 20}%`, '10%']
|
||||
: '10%'
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: Math.random() * 0.5 + 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed List */}
|
||||
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
||||
{feeds.length === 0 ? (
|
||||
<div className="text-[10px] text-cyan-700 font-mono text-center p-4">SEARCHING FREQUENCIES...</div>
|
||||
) : (
|
||||
feeds.map((feed: any, idx: number) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
onClick={() => playFeed(feed)}
|
||||
className={`p-2 mb-1 rounded cursor-pointer border-l-2 ${activeFeed?.id === feed.id ? 'bg-cyan-900/30 border-cyan-400' : 'border-transparent hover:bg-white/5'} flex justify-between items-center transition-colors`}
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden pr-2">
|
||||
<span className={`text-[11px] font-mono truncate ${activeFeed?.id === feed.id ? 'text-cyan-300' : 'text-gray-300'}`}>
|
||||
{feed.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-500 font-mono truncate">
|
||||
{feed.location} | {feed.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end flex-shrink-0">
|
||||
<span className="text-[10px] text-cyan-600 font-mono flex items-center gap-1">
|
||||
<Activity size={10} />
|
||||
{feed.listeners.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[8px] text-gray-600 font-mono mt-0.5">LSTN</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { Ruler, X, Trash2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Dynamic Scale Bar with:
|
||||
* 1. Auto-scaling distance display based on zoom level
|
||||
* 2. Draggable right edge to manually resize the ruler
|
||||
* 3. Measurement mode toggle — lets the user place up to 3 waypoints on the map
|
||||
*/
|
||||
|
||||
const NICE_METRIC = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000];
|
||||
const NICE_IMPERIAL = [0.1, 0.25, 0.5, 1, 2, 5, 10, 25, 50, 100, 200, 500, 1000, 2000, 5000];
|
||||
|
||||
const MILES_PER_METER = 0.000621371;
|
||||
const KM_PER_METER = 0.001;
|
||||
|
||||
/** Metres per pixel at a given zoom & latitude (Web Mercator). */
|
||||
function metersPerPixel(zoom: number, latitude: number) {
|
||||
return (156543.03392 * Math.cos((latitude * Math.PI) / 180)) / Math.pow(2, zoom);
|
||||
}
|
||||
|
||||
/** Format a metric distance nicely. */
|
||||
function fmtMetric(km: number) {
|
||||
return km >= 1 ? `${km.toFixed(km < 10 ? 1 : 0)} km` : `${Math.round(km * 1000)} m`;
|
||||
}
|
||||
/** Format an imperial distance nicely. */
|
||||
function fmtImperial(mi: number) {
|
||||
return mi >= 1 ? `${mi.toFixed(mi < 10 ? 1 : 0)} mi` : `${Math.round(mi * 5280)} ft`;
|
||||
}
|
||||
|
||||
interface MeasurePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface ScaleBarProps {
|
||||
zoom: number;
|
||||
latitude: number;
|
||||
measureMode?: boolean;
|
||||
measurePoints?: MeasurePoint[];
|
||||
onToggleMeasure?: () => void;
|
||||
onClearMeasure?: () => void;
|
||||
}
|
||||
|
||||
function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure, onClearMeasure }: ScaleBarProps) {
|
||||
const [unit, setUnit] = useState<"mi" | "km">("mi");
|
||||
const [barWidth, setBarWidth] = useState(120); // current bar width in px
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startW = useRef(0);
|
||||
|
||||
const MIN_BAR = 60;
|
||||
const MAX_BAR = 280;
|
||||
|
||||
// ── Draggable right edge ──
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
startW.current = barWidth;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [barWidth]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging.current) return;
|
||||
const dx = e.clientX - startX.current;
|
||||
setBarWidth(Math.max(MIN_BAR, Math.min(MAX_BAR, startW.current + dx)));
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragging.current = false;
|
||||
}, []);
|
||||
|
||||
// ── Distance label for the current bar width ──
|
||||
const scaleLabel = useMemo(() => {
|
||||
const mpp = metersPerPixel(zoom, latitude);
|
||||
const totalMeters = mpp * barWidth;
|
||||
if (unit === "km") {
|
||||
return fmtMetric(totalMeters * KM_PER_METER);
|
||||
} else {
|
||||
return fmtImperial(totalMeters * MILES_PER_METER);
|
||||
}
|
||||
}, [zoom, latitude, barWidth, unit]);
|
||||
|
||||
// ── Measurement distances ──
|
||||
const segmentDistances = useMemo(() => {
|
||||
if (!measurePoints || measurePoints.length < 2) return [];
|
||||
const dists: string[] = [];
|
||||
let total = 0;
|
||||
for (let i = 1; i < measurePoints.length; i++) {
|
||||
const d = haversine(measurePoints[i - 1], measurePoints[i]);
|
||||
total += d;
|
||||
if (unit === "km") dists.push(fmtMetric(d / 1000));
|
||||
else dists.push(fmtImperial(d * MILES_PER_METER));
|
||||
}
|
||||
if (measurePoints.length > 2) {
|
||||
if (unit === "km") dists.push(`Σ ${fmtMetric(total / 1000)}`);
|
||||
else dists.push(`Σ ${fmtImperial(total * MILES_PER_METER)}`);
|
||||
}
|
||||
return dists;
|
||||
}, [measurePoints, unit]);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-3 select-none">
|
||||
{/* Scale ruler */}
|
||||
<div className="flex flex-col items-start">
|
||||
<div
|
||||
className="flex items-end relative"
|
||||
style={{ width: barWidth }}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{/* Left tick */}
|
||||
<div className="w-px h-2.5 bg-cyan-400 flex-shrink-0" />
|
||||
{/* Bar */}
|
||||
<div className="flex-1 h-px bg-cyan-400 relative" style={{ boxShadow: "0 0 6px rgba(0,255,255,0.3)" }}>
|
||||
{/* Graduation marks */}
|
||||
<div className="absolute left-1/4 top-0 w-px h-1.5 bg-cyan-400/50" />
|
||||
<div className="absolute left-1/2 top-0 w-px h-2 bg-cyan-400/70" />
|
||||
<div className="absolute left-3/4 top-0 w-px h-1.5 bg-cyan-400/50" />
|
||||
</div>
|
||||
{/* Draggable right tick */}
|
||||
<div
|
||||
className="w-2 h-3 bg-cyan-400/80 rounded-r cursor-ew-resize flex-shrink-0 hover:bg-cyan-300 transition-colors"
|
||||
onPointerDown={onPointerDown}
|
||||
title="Drag to resize scale"
|
||||
style={{ touchAction: "none" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-cyan-300 tracking-widest mt-0.5">{scaleLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Unit toggle */}
|
||||
<button
|
||||
onClick={() => setUnit(u => u === "mi" ? "km" : "mi")}
|
||||
className="text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-gray-700 hover:border-cyan-500/50 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 uppercase"
|
||||
title={`Switch to ${unit === "mi" ? "Metric (km)" : "Imperial (mi)"}`}
|
||||
>
|
||||
{unit === "mi" ? "MI" : "KM"}
|
||||
</button>
|
||||
|
||||
{/* Measure mode toggle */}
|
||||
<button
|
||||
onClick={onToggleMeasure}
|
||||
className={`flex items-center gap-1 text-[8px] font-mono tracking-widest px-2 py-0.5 rounded border transition-all ${measureMode
|
||||
? "border-cyan-500/60 text-cyan-400 bg-cyan-950/30 shadow-[0_0_8px_rgba(0,255,255,0.2)]"
|
||||
: "border-gray-700 text-gray-500 hover:text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-950/20"
|
||||
}`}
|
||||
title={measureMode ? "Exit measurement mode" : "Measure distance (click up to 3 points)"}
|
||||
>
|
||||
<Ruler size={10} />
|
||||
{measureMode ? "MEASURING" : "MEASURE"}
|
||||
</button>
|
||||
|
||||
{/* Clear measurements */}
|
||||
{measureMode && measurePoints && measurePoints.length > 0 && (
|
||||
<button
|
||||
onClick={onClearMeasure}
|
||||
className="flex items-center gap-1 text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-gray-700 text-gray-500 hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/20 transition-all"
|
||||
title="Clear all waypoints"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Segment distances readout */}
|
||||
{segmentDistances.length > 0 && (
|
||||
<div className="flex items-center gap-2 ml-1">
|
||||
{segmentDistances.map((d, i) => (
|
||||
<span key={i} className={`text-[9px] font-mono tracking-wider px-1.5 py-0.5 rounded border ${d.startsWith("Σ")
|
||||
? "border-cyan-500/50 text-cyan-300 bg-cyan-950/30"
|
||||
: "border-gray-700 text-gray-400"
|
||||
}`}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Haversine distance in meters between two lat/lng points. */
|
||||
function haversine(a: MeasurePoint, b: MeasurePoint): number {
|
||||
const R = 6371000;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const sa = Math.sin(dLat / 2);
|
||||
const sb = Math.sin(dLng / 2);
|
||||
const h = sa * sa + Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * sb * sb;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
export default React.memo(ScaleBar);
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Settings, Eye, EyeOff, Copy, Check, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
interface ApiEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
url: string | null;
|
||||
required: boolean;
|
||||
has_key: boolean;
|
||||
env_key: string | null;
|
||||
value_obfuscated: string | null;
|
||||
value_plain: string | null;
|
||||
}
|
||||
|
||||
// Category colors for the tactical UI
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
||||
Maritime: "text-blue-400 border-blue-500/30 bg-blue-950/20",
|
||||
Geophysical: "text-orange-400 border-orange-500/30 bg-orange-950/20",
|
||||
Space: "text-purple-400 border-purple-500/30 bg-purple-950/20",
|
||||
Intelligence: "text-red-400 border-red-500/30 bg-red-950/20",
|
||||
Geolocation: "text-green-400 border-green-500/30 bg-green-950/20",
|
||||
Weather: "text-yellow-400 border-yellow-500/30 bg-yellow-950/20",
|
||||
Markets: "text-emerald-400 border-emerald-500/30 bg-emerald-950/20",
|
||||
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
|
||||
};
|
||||
|
||||
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
const [apis, setApis] = useState<ApiEntry[]>([]);
|
||||
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/settings/api-keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setApis(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch API keys", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchKeys();
|
||||
}, [isOpen, fetchKeys]);
|
||||
|
||||
const toggleReveal = (id: string) => {
|
||||
setRevealedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (id: string, value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch {
|
||||
// Clipboard API may fail in some contexts
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (api: ApiEntry) => {
|
||||
setEditingId(api.id);
|
||||
setEditValue(api.value_plain || "");
|
||||
};
|
||||
|
||||
const saveKey = async (api: ApiEntry) => {
|
||||
if (!api.env_key) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/settings/api-keys", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEditingId(null);
|
||||
fetchKeys(); // Refresh to get new obfuscated value
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save API key", e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat);
|
||||
else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Group APIs by category
|
||||
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
||||
if (!acc[api.category]) acc[api.category] = [];
|
||||
acc[api.category].push(api);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -300 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -300 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed left-0 top-0 bottom-0 w-[480px] bg-gray-950/95 backdrop-blur-xl border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.8)]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-800/80">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||
<Settings size={16} className="text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">SYSTEM CONFIG</h2>
|
||||
<span className="text-[9px] text-gray-500 font-mono tracking-widest">API KEY REGISTRY</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] text-gray-400 font-mono leading-relaxed">
|
||||
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API List */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
||||
{Object.entries(grouped).map(([category, categoryApis]) => {
|
||||
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
|
||||
return (
|
||||
<div key={category} className="rounded-lg border border-gray-800/60 overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
||||
{category.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp size={12} className="text-gray-500" /> : <ChevronDown size={12} className="text-gray-500" />}
|
||||
</button>
|
||||
|
||||
{/* APIs in Category */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{categoryApis.map((api) => (
|
||||
<div key={api.id} className="border-t border-gray-800/40 px-4 py-3 hover:bg-gray-900/30 transition-colors">
|
||||
{/* API Name + Status */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{api.required && <Key size={10} className="text-yellow-500" />}
|
||||
<span className="text-xs font-mono text-white font-medium">{api.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{api.has_key ? (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">
|
||||
KEY SET
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-gray-700 text-gray-500">
|
||||
PUBLIC
|
||||
</span>
|
||||
)}
|
||||
{api.url && (
|
||||
<a
|
||||
href={api.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-cyan-400 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[10px] text-gray-500 font-mono leading-relaxed mb-2">
|
||||
{api.description}
|
||||
</p>
|
||||
|
||||
{/* Key Field (only for APIs with keys) */}
|
||||
{api.has_key && (
|
||||
<div className="mt-2">
|
||||
{editingId === api.id ? (
|
||||
/* Edit Mode */
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
|
||||
placeholder="Enter API key..."
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveKey(api)}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1"
|
||||
>
|
||||
<Save size={10} />
|
||||
{saving ? "..." : "SAVE"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="px-2 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-white hover:border-gray-600 transition-colors text-[10px] font-mono"
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Display Mode */
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="flex-1 bg-black/40 border border-gray-800 rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-gray-700 transition-colors select-none"
|
||||
onClick={() => startEditing(api)}
|
||||
>
|
||||
<span className={revealedKeys.has(api.id) ? "text-cyan-300" : "text-gray-500 tracking-wider"}>
|
||||
{revealedKeys.has(api.id) ? api.value_plain : api.value_obfuscated}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Eye Toggle */}
|
||||
<button
|
||||
onClick={() => toggleReveal(api.id)}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center border transition-all ${revealedKeys.has(api.id)
|
||||
? "border-cyan-500/40 text-cyan-400 bg-cyan-950/30"
|
||||
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
||||
}`}
|
||||
title={revealedKeys.has(api.id) ? "Hide key" : "Reveal key"}
|
||||
>
|
||||
{revealedKeys.has(api.id) ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
|
||||
{/* Copy */}
|
||||
<button
|
||||
onClick={() => copyToClipboard(api.id, api.value_plain || "")}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center border transition-all ${copiedId === api.id
|
||||
? "border-green-500/40 text-green-400 bg-green-950/30"
|
||||
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
||||
}`}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedId === api.id ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-800/80">
|
||||
<div className="flex items-center justify-between text-[9px] text-gray-600 font-mono">
|
||||
<span>{apis.length} REGISTERED APIs</span>
|
||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
// Module-level cache: Wikipedia article title → thumbnail URL
|
||||
const _cache: Record<string, { url: string | null; done: boolean }> = {};
|
||||
|
||||
/**
|
||||
* WikiImage — displays a Wikipedia thumbnail for a given article URL.
|
||||
* Uses the Wikipedia REST API with a module-level cache (only fetches once per article).
|
||||
*
|
||||
* Props:
|
||||
* wikiUrl: Full Wikipedia URL, e.g. "https://en.wikipedia.org/wiki/Boeing_787_Dreamliner"
|
||||
* label: Alt text / label for the image link
|
||||
* maxH: Max height class (default "max-h-32")
|
||||
* accent: Border hover color class (default "hover:border-cyan-500/50")
|
||||
*/
|
||||
export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent = 'hover:border-cyan-500/50' }: {
|
||||
wikiUrl: string;
|
||||
label?: string;
|
||||
maxH?: string;
|
||||
accent?: string;
|
||||
}) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Extract article title from URL
|
||||
const title = wikiUrl.replace(/^https?:\/\/[^/]+\/wiki\//, '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!title || _cache[title]?.done) return;
|
||||
if (_cache[title]) return; // In-flight
|
||||
_cache[title] = { url: null, done: false };
|
||||
|
||||
fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
_cache[title] = { url: d.thumbnail?.source || d.originalimage?.source || null, done: true };
|
||||
forceUpdate(n => n + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
_cache[title] = { url: null, done: true };
|
||||
forceUpdate(n => n + 1);
|
||||
});
|
||||
}, [title]);
|
||||
|
||||
const cached = _cache[title];
|
||||
const imgUrl = cached?.url;
|
||||
const loading = cached && !cached.done;
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
{loading && (
|
||||
<div className={`w-full h-20 rounded bg-gray-800/60 animate-pulse`} />
|
||||
)}
|
||||
{imgUrl && (
|
||||
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={label || title.replace(/_/g, ' ')}
|
||||
className={`w-full h-auto ${maxH} object-cover rounded border border-gray-700/50 ${accent} transition-colors`}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<a href={wikiUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline mt-1 inline-block font-mono">
|
||||
📖 {label || title.replace(/_/g, ' ')} — Wikipedia →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, BookOpen, Radio } from "lucide-react";
|
||||
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void }) {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
|
||||
// Compute ship category counts
|
||||
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0;
|
||||
const passengerShipCount = data?.ships?.filter((s: any) => s.type === 'passenger')?.length || 0;
|
||||
const civilianShipCount = data?.ships?.filter((s: any) => !['carrier', 'military_vessel', 'tanker', 'cargo', 'passenger'].includes(s.type))?.length || 0;
|
||||
|
||||
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: "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: "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-gray-400 font-mono tracking-widest mb-1">TOP SECRET // SI-TK // NOFORN</div>
|
||||
<div className="text-[10px] text-gray-500 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-cyan-50">FLIR</h1>
|
||||
{onSettingsClick && (
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
className="w-7 h-7 rounded-lg border border-gray-700 hover:border-cyan-500/50 flex items-center justify-center text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 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-gray-700 hover:border-cyan-500/50 flex items-center justify-center gap-1 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20"
|
||||
title="Map Legend / Icon Key"
|
||||
>
|
||||
<BookOpen size={12} />
|
||||
<span className="text-[8px] font-mono tracking-widest font-bold">KEY</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Layers Box */}
|
||||
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] flex flex-col relative overflow-hidden max-h-full">
|
||||
|
||||
{/* Header / Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA LAYERS</span>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</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">
|
||||
{layers.map((layer, idx) => {
|
||||
const Icon = layer.icon;
|
||||
const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
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-white' : 'text-gray-400'} tracking-wide`}>{layer.name}</span>
|
||||
<span className="text-[9px] text-gray-600 font-mono tracking-wider mt-0.5">{layer.source} · {active ? '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-gray-800 text-gray-600 bg-transparent'
|
||||
}`}>
|
||||
{active ? 'ON' : 'OFF'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorldviewLeftPanel;
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, setEffects, setUiVisible }: { effects: any; setEffects: any; setUiVisible: any }) {
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState({ date: "XXXX-XX-XX", time: "00:00:00" });
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
setCurrentTime({
|
||||
date: now.toISOString().slice(0, 10),
|
||||
time: now.toISOString().slice(11, 19)
|
||||
});
|
||||
};
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className={`w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 flex-shrink-0 ${isMinimized ? 'h-[50px]' : 'h-[320px]'}`}
|
||||
>
|
||||
{/* Record / Orbit Tracker Header */}
|
||||
<div className="flex items-center gap-3 mb-6 border border-gray-800 bg-black/40 backdrop-blur-md px-4 py-2 rounded-sm relative shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto">
|
||||
<div className="absolute -top-1 -left-1 w-2 h-2 border-t border-l border-gray-500/50"></div>
|
||||
<div className="absolute -bottom-1 -right-1 w-2 h-2 border-b border-r border-gray-500/50"></div>
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
||||
<div className="text-[10px] font-mono text-gray-400 tracking-wider">
|
||||
REC {currentTime.date} {currentTime.time}
|
||||
<br />
|
||||
ORB: 47696 PASS: DESC-284
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side controls box */}
|
||||
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
|
||||
|
||||
{/* Header / Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DISPLAY CONFIG</span>
|
||||
<button className="text-gray-500 hover:text-white transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</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-4">
|
||||
|
||||
{/* Bloom Toggle */}
|
||||
<div
|
||||
className={`flex items-center justify-between group cursor-pointer border rounded px-4 py-3 transition-colors ${effects.bloom ? 'border-yellow-900/50 bg-yellow-950/10' : 'border-gray-800'}`}
|
||||
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-xs font-mono tracking-widest ${effects.bloom ? 'text-white' : 'text-gray-500'}`}>BLOOM</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono tracking-wider text-gray-500">{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="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">
|
||||
<span className="w-1.5 h-1.5 bg-cyan-400 rounded-full"></span>
|
||||
</span>
|
||||
<span className="text-xs font-mono tracking-widest text-cyan-400 font-bold">SHARPEN</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 mt-1">
|
||||
<div className="h-0.5 bg-gray-800 flex-1 relative rounded-full">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[49%] bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.5)]"></div>
|
||||
<div className="absolute left-[49%] top-1/2 -translate-y-1/2 w-2 h-2 bg-white rounded-full"></div>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-cyan-400">49%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HUD Dropdown */}
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<div className="flex items-center gap-3 border border-gray-800 rounded px-4 py-3 text-gray-500 cursor-default">
|
||||
<span className="w-3 h-3 border border-gray-500 rounded-full flex items-center justify-center"></span>
|
||||
<span className="text-xs font-mono tracking-widest">HUD</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border border-gray-800 rounded px-4 py-2 mt-1 bg-black/50">
|
||||
<span className="text-[10px] text-gray-500 font-mono">LAYOUT</span>
|
||||
<span className="text-xs text-white tracking-widest border-b border-dashed border-gray-600 pb-0.5 cursor-pointer flex items-center gap-2">
|
||||
Tactical
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full border border-red-900/30 bg-red-950/10 rounded py-3 mt-2 text-[10px] font-mono tracking-widest text-red-500 hover:text-white hover:bg-red-900 hover:border-red-600 transition-all font-bold"
|
||||
onClick={() => setUiVisible(false)}
|
||||
>
|
||||
CLEAR UI (TACTICAL MODE)
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorldviewRightPanel;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare module '@mapbox/point-geometry';
|
||||
declare module 'mapbox__point-geometry';
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Solar Terminator — computes the day/night boundary polygon for real-time rendering.
|
||||
*
|
||||
* Uses simplified astronomical formulas to compute the subsolar point,
|
||||
* then generates a GeoJSON polygon covering the nighttime hemisphere.
|
||||
*
|
||||
* Performance: pure math, no API calls, ~0.1ms per computation.
|
||||
* The polygon has 360 vertices (one per degree of longitude) — trivial for MapLibre.
|
||||
*/
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const RAD = 180 / Math.PI;
|
||||
|
||||
/**
|
||||
* Compute the Sun's declination and equation of time for a given JS Date.
|
||||
* Returns: { declination (radians), eqTime (minutes) }
|
||||
*/
|
||||
function solarPosition(date: Date) {
|
||||
const start = new Date(date.getFullYear(), 0, 0);
|
||||
const diff = date.getTime() - start.getTime();
|
||||
const oneDay = 1000 * 60 * 60 * 24;
|
||||
const dayOfYear = Math.floor(diff / oneDay);
|
||||
const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
||||
|
||||
// Fractional year in radians
|
||||
const gamma = (2 * Math.PI / 365) * (dayOfYear - 1 + (hour - 12) / 24);
|
||||
|
||||
// Equation of time (minutes)
|
||||
const eqTime = 229.18 * (
|
||||
0.000075
|
||||
+ 0.001868 * Math.cos(gamma)
|
||||
- 0.032077 * Math.sin(gamma)
|
||||
- 0.014615 * Math.cos(2 * gamma)
|
||||
- 0.040849 * Math.sin(2 * gamma)
|
||||
);
|
||||
|
||||
// Solar declination (radians)
|
||||
const declination =
|
||||
0.006918
|
||||
- 0.399912 * Math.cos(gamma)
|
||||
+ 0.070257 * Math.sin(gamma)
|
||||
- 0.006758 * Math.cos(2 * gamma)
|
||||
+ 0.000907 * Math.sin(2 * gamma)
|
||||
- 0.002697 * Math.cos(3 * gamma)
|
||||
+ 0.00148 * Math.sin(3 * gamma);
|
||||
|
||||
return { declination, eqTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given longitude, compute the latitude of the terminator line.
|
||||
* Returns the latitude (in degrees) where the sun angle = 0.
|
||||
*/
|
||||
function terminatorLatitude(lng: number, declination: number, subsolarLng: number): number {
|
||||
// Hour angle at this longitude
|
||||
const ha = (lng - subsolarLng) * DEG;
|
||||
// Terminator: cos(zenith) = 0 => sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha) = 0
|
||||
// => tan(lat) = -cos(ha) * cos(dec) / sin(dec)
|
||||
// => lat = atan(-cos(ha) / tan(dec))
|
||||
|
||||
const tanDec = Math.tan(declination);
|
||||
if (Math.abs(tanDec) < 1e-10) {
|
||||
// Near equinox, terminator is roughly at ±90° adjusted
|
||||
return -Math.acos(0) * RAD; // fallback
|
||||
}
|
||||
const lat = Math.atan(-Math.cos(ha) / tanDec) * RAD;
|
||||
return lat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a GeoJSON FeatureCollection containing the nighttime polygon.
|
||||
* Updated every call with the current date.
|
||||
*/
|
||||
export function computeNightPolygon(date: Date = new Date()): GeoJSON.FeatureCollection {
|
||||
const { declination, eqTime } = solarPosition(date);
|
||||
|
||||
// Subsolar longitude: where the sun is directly overhead
|
||||
const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
||||
const subsolarLng = -(hour - 12) * 15 - eqTime / 4; // degrees
|
||||
|
||||
// Generate terminator line points (one per degree of longitude)
|
||||
const terminatorPoints: [number, number][] = [];
|
||||
for (let lng = -180; lng <= 180; lng += 1) {
|
||||
const lat = terminatorLatitude(lng, declination, subsolarLng);
|
||||
// Clamp latitude to valid range
|
||||
terminatorPoints.push([lng, Math.max(-85, Math.min(85, lat))]);
|
||||
}
|
||||
|
||||
// Determine which side is night: if declination > 0 (northern summer),
|
||||
// the night polygon is on the south side of the terminator, and vice versa.
|
||||
// More precisely: at lng=subsolarLng, the sun is overhead, so the opposite side is night.
|
||||
// We check: is the subsolar point on the +lat or -lat side of the terminator at that lng?
|
||||
|
||||
// The subsolar latitude
|
||||
const subsolarLat = declination * RAD;
|
||||
// The terminator latitude at the subsolar longitude
|
||||
const termLatAtSubsolar = terminatorLatitude(subsolarLng, declination, subsolarLng);
|
||||
|
||||
// If subsolar lat > terminator lat at that point, night is on the south (below terminator)
|
||||
const nightIsSouth = subsolarLat > termLatAtSubsolar;
|
||||
|
||||
// Build the night polygon
|
||||
// South side: terminator -> bottom edge (-85) -> close
|
||||
// North side: terminator -> top edge (85) -> close
|
||||
const nightCoords: [number, number][] = [];
|
||||
|
||||
if (nightIsSouth) {
|
||||
// Night is below the terminator line
|
||||
// Go left-to-right along the terminator, then close along the bottom
|
||||
for (const pt of terminatorPoints) nightCoords.push(pt);
|
||||
nightCoords.push([180, -85]);
|
||||
nightCoords.push([-180, -85]);
|
||||
} else {
|
||||
// Night is above the terminator line
|
||||
for (const pt of terminatorPoints) nightCoords.push(pt);
|
||||
nightCoords.push([180, 85]);
|
||||
nightCoords.push([-180, 85]);
|
||||
}
|
||||
|
||||
// Close the ring
|
||||
nightCoords.push(nightCoords[0]);
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [nightCoords],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user