Initial commit

This commit is contained in:
denshooter
2026-03-09 22:07:19 +01:00
commit daef092099
55 changed files with 39435 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GOD'S EYE — Global Intelligence Platform</title>
<meta name="description" content="Real-time global intelligence and surveillance platform" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4653
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"@types/geojson": "^7946.0.16",
"globe.gl": "^2.45.0",
"lucide-react": "^0.577.0",
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-use-websocket": "^4.13.0",
"tailwindcss": "^4.2.1",
"three": "^0.183.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
/* App-level overrides — keep empty; global styles live in index.css */
+555
View File
@@ -0,0 +1,555 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import useWebSocket, { ReadyState } from 'react-use-websocket';
import {
Shield, Plane, Satellite, X, Camera, Activity, Clock, Anchor, MapPin,
Zap, Compass, Eye, Layers, Radio, Crosshair, AlertTriangle, Globe, Wifi, WifiOff,
} from 'lucide-react';
const GlobeView = lazy(() => import('./components/GlobeView'));
function resolveWsUrl(): string {
const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
if (envUrl && envUrl.trim().length > 0) return envUrl.trim();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.hostname}:8000/ws`;
}
function timeAgo(dateStr: string): string {
try {
const diff = Date.now() - new Date(dateStr).getTime();
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
} catch { return ''; }
}
const THREAT_CONFIG: Record<string, { cls: string; glow: string; bar: string }> = {
SEVERE: { cls: 'bg-red-500/20 text-red-400 border-red-500/50', glow: 'shadow-[0_0_20px_rgba(239,68,68,0.4)]', bar: 'bg-red-500' },
HIGH: { cls: 'bg-orange-500/20 text-orange-400 border-orange-500/50', glow: 'shadow-[0_0_20px_rgba(249,115,22,0.4)]', bar: 'bg-orange-500' },
ELEVATED: { cls: 'bg-amber-500/20 text-amber-300 border-amber-500/50', glow: 'shadow-[0_0_15px_rgba(245,158,11,0.3)]', bar: 'bg-amber-400' },
GUARDED: { cls: 'bg-blue-500/20 text-blue-300 border-blue-500/40', glow: 'shadow-[0_0_15px_rgba(59,130,246,0.3)]', bar: 'bg-blue-500' },
LOW: { cls: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/40', glow: '', bar: 'bg-emerald-500' },
UNKNOWN: { cls: 'bg-slate-800/40 text-slate-400 border-slate-600/50', glow: '', bar: 'bg-slate-600' },
OFFLINE: { cls: 'bg-slate-900/60 text-slate-500 border-slate-700/50', glow: '', bar: 'bg-slate-700' },
};
function App() {
const wsUrl = useMemo(resolveWsUrl, []);
const lastUpdateSigRef = useRef<string>('');
const [rawData, setRawData] = useState<any>({
planes: [], satellites: [], news: [], gpsInterference: [],
webcams: [], ships: [], emergencySquawks: [],
conflicts: [], cyber_attacks: [],
aiAnalysis: { summary: "Initializing Global Surveillance...", threat_level: 'UNKNOWN', predictions: [] },
bgp: {}, last_updated: {},
});
const [filters, setFilters] = useState({
showPlanes: true, showSatellites: true, showShips: true, showWebcams: false,
showNews: true, showConflicts: true, showCyber: true, militaryOnlySats: false,
jammingOnlyPlanes: false, stationsVisible: true,
});
const [selectedItem, setSelectedItem] = useState<any>(null);
const [showFilterPanel, setShowFilterPanel] = useState(false);
const [feedTab, setFeedTab] = useState<'brief' | 'live' | 'predictions'>('brief');
const [webcamViewer, setWebcamViewer] = useState<any>(null);
const [camPov, setCamPov] = useState<{ lat: number; lng: number; altitude: number } | null>(null);
const [showReticle, setShowReticle] = useState(false);
const historyModeRef = useRef(false);
const [utcTime, setUtcTime] = useState('');
useEffect(() => {
const update = () => setUtcTime(new Date().toISOString().replace('T', ' ').slice(0, 19) + 'Z');
update();
const t = setInterval(update, 1000);
return () => clearInterval(t);
}, []);
const { lastJsonMessage, readyState, sendJsonMessage } = useWebSocket(wsUrl, { shouldReconnect: () => true, reconnectInterval: 3000 });
useEffect(() => {
(window as any).__GODS_EYE_REQUEST_SECTOR__ = (box: any) => sendJsonMessage({ type: 'request_sector', box });
return () => { (window as any).__GODS_EYE_REQUEST_SECTOR__ = null; };
}, [sendJsonMessage]);
const [systemStatus, setSystemStatus] = useState('Initializing Sentinel...');
useEffect(() => {
const msg = lastJsonMessage as any;
if (msg?.type === 'status') { setSystemStatus(msg.message); return; }
if (msg?.type === 'sector_update') { (window as any).__GODS_EYE_ON_SECTOR_DATA__?.(msg); return; }
if (msg?.type === 'update' && !historyModeRef.current) {
const updateSig = JSON.stringify(msg.last_updated || {});
if (updateSig === lastUpdateSigRef.current) return;
lastUpdateSigRef.current = updateSig;
setRawData((prev: any) => ({
...prev,
planes: msg.planes || prev.planes,
satellites: msg.satellites || prev.satellites,
news: msg.news || prev.news,
gpsInterference: msg.gps_interference || prev.gpsInterference,
webcams: msg.webcams || prev.webcams,
ships: msg.ships || prev.ships,
emergencySquawks: msg.emergency_squawks || prev.emergencySquawks,
conflicts: msg.conflicts || prev.conflicts,
cyber_attacks: msg.cyber_attacks || prev.cyber_attacks,
aiAnalysis: msg.ai_analysis || prev.aiAnalysis,
bgp: msg.bgp || prev.bgp,
last_updated: msg.last_updated || prev.last_updated,
}));
}
}, [lastJsonMessage]);
const isOnline = readyState === ReadyState.OPEN;
const threatLevel = rawData.aiAnalysis?.threat_level || 'UNKNOWN';
const threatConf = THREAT_CONFIG[threatLevel] || THREAT_CONFIG.UNKNOWN;
const criticalConflicts = useMemo(() => (rawData.conflicts || []).filter((c: any) => c.severity === 'CRITICAL'), [rawData.conflicts]);
const milPlanes = useMemo(() => (rawData.planes || []).filter((p: any) => p.military), [rawData.planes]);
const liveFeed = useMemo(() => {
return [...(rawData.news || []).map((n: any) => ({ ...n, _feedType: 'news' })), ...(rawData.conflicts || []).map((c: any) => ({ ...c, _feedType: 'conflict' }))]
.sort((a, b) => new Date(b.published_at || 0).getTime() - new Date(a.published_at || 0).getTime()).slice(0, 80);
}, [rawData.news, rawData.conflicts]);
return (
<div className="relative w-screen h-screen overflow-hidden bg-[#020617] text-slate-200 font-mono select-none antialiased">
{/* GLOBE */}
<div className="absolute inset-0">
<Suspense fallback={
<div className="absolute inset-0 bg-[#020617] flex items-center justify-center z-50">
<div className="text-center">
<div className="w-12 h-12 border-2 border-sky-500/20 border-t-sky-400 rounded-full animate-spin mx-auto mb-4" />
<p className="text-[10px] text-sky-400 font-bold uppercase tracking-[0.4em] animate-pulse">{systemStatus}</p>
</div>
</div>
}>
<GlobeView data={rawData} selectedItem={selectedItem} onSelectItem={(item) => item?.dataType === 'webcam' ? setWebcamViewer(item) : setSelectedItem(item)} onPovChange={setCamPov} />
</Suspense>
</div>
{/* TARGETING RETICLE */}
{showReticle && (
<div className="absolute inset-0 pointer-events-none z-10 flex items-center justify-center">
<div className="relative w-32 h-32">
{/* Corner brackets */}
<div className="absolute top-0 left-0 w-5 h-5 border-t-2 border-l-2 border-rose-400/70" />
<div className="absolute top-0 right-0 w-5 h-5 border-t-2 border-r-2 border-rose-400/70" />
<div className="absolute bottom-0 left-0 w-5 h-5 border-b-2 border-l-2 border-rose-400/70" />
<div className="absolute bottom-0 right-0 w-5 h-5 border-b-2 border-r-2 border-rose-400/70" />
{/* Center dot */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-rose-400/80" />
</div>
{/* Crosshair lines */}
<div className="absolute top-1/2 left-0 right-0 h-px bg-rose-400/30" />
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-rose-400/30" />
</div>
{camPov && (
<div className="absolute" style={{ top: 'calc(50% + 72px)', transform: 'translateX(-50%)', left: '50%' }}>
<span className="text-[9px] text-rose-400/70 font-mono tracking-widest bg-black/40 px-2 py-0.5 rounded">
{camPov.lat >= 0 ? 'N' : 'S'}{Math.abs(camPov.lat).toFixed(4)}° {camPov.lng >= 0 ? 'E' : 'W'}{Math.abs(camPov.lng).toFixed(4)}°
</span>
</div>
)}
</div>
)}
{/* VIGNETTE */}
<div className="absolute inset-0 pointer-events-none z-5" style={{
background: 'radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.6) 100%)',
}} />
{/* TOP COMMAND BAR */}
<header className="absolute top-0 left-0 right-0 h-14 z-20 flex items-center justify-between px-4 bg-gradient-to-b from-[#020617]/95 to-transparent backdrop-blur-sm pointer-events-none border-b border-slate-800/40">
{/* LEFT: LOGO + STATUS */}
<div className="flex items-center gap-4 pointer-events-auto">
<div className="flex items-center gap-2.5">
<div className="relative flex items-center justify-center w-8 h-8 rounded-lg bg-sky-900/40 border border-sky-500/40 shadow-[0_0_15px_rgba(14,165,233,0.25)]">
<Shield size={15} className="text-sky-400" />
</div>
<div>
<div className="text-sm font-black tracking-[0.2em] text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.4)]">GOD'S EYE</div>
<div className="text-[8px] text-sky-500 tracking-[0.35em] font-bold uppercase">Strategic Command</div>
</div>
</div>
<div className="h-5 w-px bg-slate-700/60" />
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded border text-[9px] font-bold tracking-wider ${isOnline ? 'border-emerald-500/30 text-emerald-400' : 'border-red-500/30 text-red-400 animate-pulse'}`}>
{isOnline ? <Wifi size={10}/> : <WifiOff size={10}/>}
{isOnline ? 'DATALINK SECURE' : 'LINK LOST'}
</div>
<div className="flex items-center gap-1.5 bg-slate-900/60 border border-slate-700/50 px-2.5 py-1 rounded text-[9px] text-slate-400">
<Clock size={10} className="text-sky-500" /> {utcTime}
</div>
</div>
{/* CENTER: THREAT LEVEL */}
<div className={`flex items-center gap-3 px-4 py-1.5 rounded-lg border ${threatConf.cls} ${threatConf.glow} backdrop-blur-sm`}>
<div className={`w-2 h-2 rounded-full ${threatConf.bar} animate-pulse`} />
<span className="text-[9px] text-slate-400 tracking-widest">THREAT</span>
<span className="text-sm font-black tracking-wider">{threatLevel}</span>
</div>
{/* RIGHT: STATS + CONTROLS */}
<div className="flex items-center gap-3 pointer-events-auto">
{/* Entity counts */}
<div className="flex items-center gap-3 text-[10px] bg-slate-900/60 border border-slate-700/50 px-3 py-1.5 rounded backdrop-blur-sm">
<StatBadge icon={<Plane size={11} className="text-amber-400"/>} value={rawData.planes.length} label="AC" />
<div className="w-px h-4 bg-slate-700" />
<StatBadge icon={<Satellite size={11} className="text-sky-400"/>} value={rawData.satellites.length} label="SAT" />
<div className="w-px h-4 bg-slate-700" />
<StatBadge icon={<Anchor size={11} className="text-blue-400"/>} value={rawData.ships.length} label="VES" />
<div className="w-px h-4 bg-slate-700" />
<StatBadge icon={<AlertTriangle size={11} className="text-rose-400"/>} value={criticalConflicts.length} label="CRT" pulse={criticalConflicts.length > 0} />
</div>
{/* Reticle toggle */}
<button onClick={() => setShowReticle(r => !r)} title="Toggle targeting reticle"
className={`p-2 rounded border transition-all ${showReticle ? 'bg-rose-500/20 border-rose-400 text-rose-300' : 'bg-slate-900/60 border-slate-700 text-slate-400 hover:border-rose-500/50 hover:text-rose-300'} backdrop-blur-sm`}>
<Crosshair size={15} />
</button>
{/* Filter toggle */}
<button onClick={() => setShowFilterPanel(!showFilterPanel)}
className={`p-2 rounded border transition-all ${showFilterPanel ? 'bg-sky-500/20 border-sky-400 text-sky-300' : 'bg-slate-900/60 border-slate-700 text-slate-400 hover:border-sky-500/50 hover:text-sky-300'} backdrop-blur-sm`}>
<Layers size={15} />
</button>
</div>
</header>
{/* LEFT SIDEBAR INTELLIGENCE FEED */}
<aside className="absolute left-4 top-16 bottom-12 w-[340px] z-20 pointer-events-none flex flex-col">
<div className="pointer-events-auto h-full flex flex-col bg-slate-950/70 border border-slate-800/80 rounded-xl backdrop-blur-xl shadow-2xl shadow-black/60 overflow-hidden">
{/* Tab bar */}
<div className="flex border-b border-slate-800/80 bg-slate-900/40">
<FeedTab icon={<Radio size={10}/>} label="AI Brief" active={feedTab==='brief'} onClick={()=>setFeedTab('brief')} />
<FeedTab icon={<Activity size={10}/>} label="Live Intel" active={feedTab==='live'} onClick={()=>setFeedTab('live')} />
<FeedTab icon={<Crosshair size={10}/>} label="Predictions" active={feedTab==='predictions'} onClick={()=>setFeedTab('predictions')} />
</div>
<div className="flex-1 overflow-y-auto scrollbar-hide p-3 space-y-3">
{/* ── AI BRIEF ── */}
{feedTab === 'brief' && (
<div className="space-y-4">
{/* Summary */}
<div className="p-3 rounded-lg bg-sky-950/20 border border-sky-500/20">
<div className="flex items-center gap-1.5 mb-2">
<Radio size={9} className="text-sky-400" />
<span className="text-[9px] font-bold text-sky-500 tracking-widest uppercase">Strategic Summary</span>
<span className="ml-auto text-[8px] text-slate-600">{rawData.aiAnalysis?.source || 'heuristic'}</span>
</div>
<p className="text-[11px] leading-relaxed text-slate-300">{rawData.aiAnalysis?.summary}</p>
</div>
{/* Key Drivers */}
{rawData.aiAnalysis?.key_drivers?.length > 0 && (
<div className="space-y-1.5">
<SectionHeader icon={<Zap size={9} className="text-amber-400"/>} label="Signal Drivers" />
{rawData.aiAnalysis.key_drivers.map((d: any, i: number) => (
<div key={i} className="flex items-start gap-2.5 p-2.5 rounded-lg bg-slate-900/50 border border-slate-800/80 hover:border-slate-700 transition-colors">
<span className="text-sm mt-0.5">{d.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[10px] font-bold text-slate-200">{d.signal}</span>
<WeightBadge weight={d.weight} />
</div>
<p className="text-[9px] text-slate-500 leading-tight">{d.detail}</p>
</div>
</div>
))}
</div>
)}
{/* Regional Theaters */}
{rawData.aiAnalysis?.regional_briefs?.length > 0 && (
<div className="space-y-1.5">
<SectionHeader icon={<MapPin size={9}/>} label="Regional Theaters" />
{rawData.aiAnalysis.regional_briefs.map((r: any, i: number) => (
<div key={i}
className="p-2.5 rounded-lg bg-slate-900/50 border border-slate-800/80 hover:border-sky-500/30 transition-all cursor-pointer group"
onClick={() => setSelectedItem({ lat: r.lat, lon: r.lon, name: r.region, summary: r.summary, dataType: 'news' })}>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-slate-200 group-hover:text-sky-400 transition-colors">{r.region}</span>
<WeightBadge weight={r.status} />
</div>
<p className="text-[9px] text-slate-500 line-clamp-2 leading-snug">{r.summary}</p>
</div>
))}
</div>
)}
{/* Intel Correlations */}
{rawData.aiAnalysis?.correlations?.length > 0 && (
<div className="space-y-1.5">
<SectionHeader icon={<Compass size={9}/>} label="Intel Correlations" />
{rawData.aiAnalysis.correlations.map((c: string, i: number) => (
<div key={i} className="flex gap-2 p-2.5 rounded-lg bg-sky-500/5 border border-sky-500/10">
<div className="w-1 h-1 rounded-full bg-sky-400 mt-1.5 flex-shrink-0" />
<p className="text-[9px] text-sky-200/70 italic leading-snug">{c}</p>
</div>
))}
</div>
)}
{/* Watch Items */}
{rawData.aiAnalysis?.watch_items?.length > 0 && (
<div className="space-y-1.5">
<SectionHeader icon={<Eye size={9} className="text-rose-400"/>} label="Watch List" />
{rawData.aiAnalysis.watch_items.map((w: string, i: number) => (
<div key={i} className="flex gap-2 p-2 rounded bg-rose-500/5 border border-rose-500/10">
<span className="text-rose-400 text-[9px] mt-0.5 flex-shrink-0">▶</span>
<p className="text-[9px] text-rose-200/70 leading-snug">{w}</p>
</div>
))}
</div>
)}
</div>
)}
{/* ── LIVE INTEL ── */}
{feedTab === 'live' && (
<div className="space-y-2">
{liveFeed.map((item: any, idx: number) => (
<div key={idx} onClick={() => setSelectedItem({ ...item, dataType: item._feedType })}
className="p-2.5 rounded-lg bg-slate-900/40 border border-slate-800 hover:border-sky-500/40 hover:bg-slate-800/50 transition-all cursor-pointer group">
<div className="flex items-center gap-2 mb-1.5">
<span className={`text-[8px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wider ${item._feedType === 'conflict' ? 'bg-rose-500/20 text-rose-400' : 'bg-sky-500/20 text-sky-400'}`}>
{item.category || item.event_type || 'INTEL'}
</span>
{item._feedType === 'conflict' && item.severity && (
<span className={`text-[7px] px-1 rounded font-black ${item.severity === 'CRITICAL' ? 'bg-red-500/20 text-red-400' : item.severity === 'HIGH' ? 'bg-orange-500/20 text-orange-400' : 'bg-slate-700 text-slate-400'}`}>{item.severity}</span>
)}
<span className="ml-auto text-[8px] text-slate-600">{timeAgo(item.published_at)}</span>
</div>
<p className="text-[10px] font-medium text-slate-300 group-hover:text-white line-clamp-2 leading-snug">{item.title}</p>
</div>
))}
</div>
)}
{/* ── PREDICTIONS ── */}
{feedTab === 'predictions' && (
<div className="space-y-3">
<div className="text-[9px] text-slate-500 leading-relaxed p-2 rounded bg-slate-900/30 border border-slate-800">
AI-synthesized threat predictions from movement, OSINT, and SIGINT correlation.
</div>
{(rawData.aiAnalysis?.predictions || []).length === 0 && (
<div className="text-center py-8 text-slate-600 text-[10px]">No predictions available yet.</div>
)}
{(rawData.aiAnalysis?.predictions || []).map((p: any, i: number) => (
<div key={i}
className="p-3 rounded-lg bg-slate-900/60 border border-amber-500/20 hover:border-amber-500/40 transition-all cursor-pointer group"
onClick={() => setSelectedItem({ ...p, name: p.location, dataType: 'prediction' })}>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Crosshair size={12} className="text-amber-400 flex-shrink-0" />
<span className="text-[11px] font-bold text-amber-300 group-hover:text-amber-200">{p.location}</span>
</div>
<span className="text-[10px] font-black text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">{p.probability}</span>
</div>
<p className="text-[10px] text-slate-300 font-medium mb-1.5 line-clamp-2">{p.event}</p>
{p.reason && <p className="text-[9px] text-slate-500 leading-snug line-clamp-3">{p.reason}</p>}
<div className="flex items-center gap-1.5 mt-2 text-[8px] text-slate-600">
<MapPin size={8}/>
<span>{p.lat?.toFixed(2)}°, {p.lon?.toFixed(2)}°</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</aside>
{/* RIGHT SIDEBAR TARGET DETAILS */}
{selectedItem && (
<aside className="absolute right-4 top-16 bottom-12 w-[320px] z-20 pointer-events-none">
<div className="pointer-events-auto h-full flex flex-col bg-slate-950/80 border border-sky-900/50 rounded-xl backdrop-blur-xl shadow-[0_0_30px_rgba(14,165,233,0.15)] overflow-hidden">
<div className="p-3.5 border-b border-sky-900/50 bg-sky-950/20 flex justify-between items-start flex-shrink-0">
<div>
<span className="text-[8px] text-sky-400 font-bold tracking-widest uppercase mb-1 block">Target Acquired</span>
<h3 className="text-[13px] font-black text-white uppercase tracking-wide">
{selectedItem.callsign || selectedItem.name || selectedItem.title?.slice(0, 32) || 'Unknown Entity'}
</h3>
{selectedItem.dataType && (
<span className="text-[8px] text-sky-600 tracking-widest uppercase">{selectedItem.dataType}</span>
)}
</div>
<button onClick={() => setSelectedItem(null)} className="p-1 rounded hover:bg-rose-500/20 text-slate-500 hover:text-rose-400 transition-colors ml-2 flex-shrink-0"><X size={14}/></button>
</div>
<div className="flex-1 p-4 space-y-4 overflow-y-auto scrollbar-hide">
<div className="grid grid-cols-2 gap-2">
<DetailBox label="Latitude" value={selectedItem.lat?.toFixed(5) ?? 'N/A'} />
<DetailBox label="Longitude" value={(selectedItem.lon ?? selectedItem.lng)?.toFixed(5) ?? 'N/A'} />
{selectedItem.alt != null && <DetailBox label="Altitude" value={`${Math.round(selectedItem.alt).toLocaleString()} m`} />}
{selectedItem.velocity != null && <DetailBox label="Velocity" value={`${Math.round(selectedItem.velocity)} kt`} />}
{selectedItem.heading != null && <DetailBox label="Heading" value={`${Math.round(selectedItem.heading)}°`} />}
{selectedItem.country && <DetailBox label="Origin" value={selectedItem.country} />}
{selectedItem.vessel_type && <DetailBox label="Class" value={selectedItem.vessel_type} />}
{selectedItem.type && <DetailBox label="Type" value={selectedItem.type} />}
{selectedItem.probability && <DetailBox label="Probability" value={selectedItem.probability} />}
</div>
{(selectedItem.summary || selectedItem.title || selectedItem.event || selectedItem.reason) && (
<div className="p-3 bg-slate-900/50 border border-slate-700/50 rounded-lg text-[10px] leading-relaxed text-slate-300 space-y-2">
{selectedItem.event && <p className="font-medium text-slate-200">{selectedItem.event}</p>}
{selectedItem.reason && <p className="text-slate-400 italic">{selectedItem.reason}</p>}
{!selectedItem.event && (selectedItem.summary || selectedItem.title) && <p>{selectedItem.summary || selectedItem.title}</p>}
</div>
)}
{selectedItem.military && (
<div className="flex items-center gap-2 px-3 py-2 bg-rose-500/10 border border-rose-500/30 rounded text-[9px] text-rose-400 font-bold tracking-wider">
<AlertTriangle size={10}/> MILITARY ASSET
</div>
)}
{selectedItem.image && (
<img src={selectedItem.image} alt="Target" className="w-full h-36 object-cover rounded-lg border border-slate-700/50" />
)}
</div>
<div className="p-2.5 border-t border-slate-800/80 bg-slate-950/50 flex justify-between items-center flex-shrink-0">
<span className="text-[7px] text-slate-600 tracking-widest uppercase">ID: {selectedItem.id || selectedItem.icao || 'SYS-GEN'}</span>
<span className="text-[7px] text-slate-600 tracking-widest uppercase">{utcTime}</span>
</div>
</div>
</aside>
)}
{/* LAYER FILTER PANEL */}
{showFilterPanel && (
<div className="absolute top-16 right-16 w-56 z-30 bg-slate-950/95 border border-slate-700/80 rounded-xl p-4 backdrop-blur-xl shadow-2xl pointer-events-auto">
<h3 className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-3 border-b border-slate-800 pb-2 flex items-center gap-1.5"><Layers size={9}/> Display Layers</h3>
<div className="space-y-1.5">
<ToggleRow label="Airspace / Aircraft" active={filters.showPlanes} onClick={() => setFilters(f => ({...f, showPlanes: !f.showPlanes}))} />
<ToggleRow label="Maritime / Vessels" active={filters.showShips} onClick={() => setFilters(f => ({...f, showShips: !f.showShips}))} />
<ToggleRow label="Satellites / Orbital" active={filters.showSatellites} onClick={() => setFilters(f => ({...f, showSatellites: !f.showSatellites}))} />
<ToggleRow label="Cyber Intel" active={filters.showCyber} onClick={() => setFilters(f => ({...f, showCyber: !f.showCyber}))} />
<ToggleRow label="GPS Jamming" active={true} onClick={() => {}} />
<ToggleRow label="Conflict Events" active={filters.showConflicts} onClick={() => setFilters(f => ({...f, showConflicts: !f.showConflicts}))} />
<ToggleRow label="News Rings" active={filters.showNews} onClick={() => setFilters(f => ({...f, showNews: !f.showNews}))} />
</div>
</div>
)}
{/* BOTTOM STATUS BAR */}
<footer className="absolute bottom-0 left-0 right-0 h-9 z-20 flex items-center justify-between px-4 bg-[#020617]/90 border-t border-slate-800/60 backdrop-blur-sm pointer-events-none">
<div className="flex items-center gap-5 text-[9px] text-slate-600">
<span className="flex items-center gap-1.5"><Globe size={9} className="text-sky-700"/> GOD'S EYE v2.1</span>
<span className="flex items-center gap-1.5"><Plane size={9} className="text-amber-700"/>{rawData.planes.length.toLocaleString()} AC · <span className="text-rose-700">{milPlanes.length} MIL</span></span>
<span className="flex items-center gap-1.5"><Anchor size={9} className="text-blue-700"/>{rawData.ships.length} VES</span>
<span className="flex items-center gap-1.5"><Satellite size={9} className="text-sky-700"/>{rawData.satellites.length} SAT</span>
</div>
<div className="flex items-center gap-4 text-[9px] text-slate-600">
{camPov && (
<span className="flex items-center gap-1 text-slate-700 font-mono">
{camPov.lat >= 0 ? 'N' : 'S'}{Math.abs(camPov.lat).toFixed(3)}°&nbsp;
{camPov.lng >= 0 ? 'E' : 'W'}{Math.abs(camPov.lng).toFixed(3)}°&nbsp;
ALT {camPov.altitude.toFixed(2)}
</span>
)}
{rawData.gpsInterference?.length > 0 && (
<span className="text-rose-600 animate-pulse flex items-center gap-1"><Radio size={9}/>{rawData.gpsInterference.length} GPS JAM</span>
)}
{rawData.cyber_attacks?.length > 0 && (
<span className="text-orange-700 flex items-center gap-1"><Zap size={9}/>{rawData.cyber_attacks.length} CYBER</span>
)}
<span className={`font-bold ${isOnline ? 'text-emerald-700' : 'text-red-700 animate-pulse'}`}>
{isOnline ? '● LIVE' : '○ OFFLINE'}
</span>
<span>{utcTime}</span>
</div>
</footer>
{/* ALTITUDE LEGEND */}
<div className="absolute bottom-11 left-4 z-20 pointer-events-none flex flex-col gap-0.5">
{[
{ color: '#ff2615', label: 'MIL' },
{ color: '#00d9ff', label: '>10k m' },
{ color: '#33ff80', label: '>5k m' },
{ color: '#ffb300', label: '>2k m' },
{ color: '#ff6600', label: '>500 m' },
{ color: '#ffffff', label: 'GND' },
].map(({ color, label }) => (
<div key={label} className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color, boxShadow: `0 0 4px ${color}88` }} />
<span className="text-[7px] text-slate-600 tracking-wider">{label}</span>
</div>
))}
</div>
{/* WEBCAM MODAL */}
{webcamViewer && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm pointer-events-auto">
<div className="w-[800px] h-[500px] bg-slate-950 border border-slate-700 rounded-xl overflow-hidden shadow-2xl flex flex-col">
<div className="h-10 border-b border-slate-800 flex items-center justify-between px-4 bg-slate-900/80">
<span className="text-xs font-bold text-slate-200 tracking-wider uppercase flex items-center gap-2"><Camera size={13}/> LIVE SURVEILLANCE FEED {webcamViewer.name || webcamViewer.location}</span>
<button onClick={() => setWebcamViewer(null)} className="text-slate-400 hover:text-white transition-colors"><X size={16}/></button>
</div>
<iframe src={webcamViewer.embed_url} className="flex-1 w-full border-none" allow="autoplay; fullscreen" />
</div>
</div>
)}
</div>
);
}
// ── UI COMPONENTS ──
function StatBadge({ icon, value, label, pulse }: { icon: React.ReactNode; value: number; label: string; pulse?: boolean }) {
return (
<div className={`flex items-center gap-1 ${pulse ? 'animate-pulse' : ''}`}>
{icon}
<span className="font-bold text-white">{value.toLocaleString()}</span>
<span className="text-slate-600 text-[8px]">{label}</span>
</div>
);
}
function FeedTab({ icon, label, active, onClick }: { icon: React.ReactNode; label: string; active: boolean; onClick: () => void }) {
return (
<button onClick={onClick} className={`flex-1 py-2.5 text-[9px] font-bold uppercase tracking-widest transition-all border-b-2 flex items-center justify-center gap-1 ${active ? 'border-sky-500 text-sky-400 bg-sky-900/10' : 'border-transparent text-slate-600 hover:text-slate-400 hover:bg-white/3'}`}>
{icon}{label}
</button>
);
}
function SectionHeader({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="flex items-center gap-1.5 text-[9px] font-bold text-sky-500 tracking-widest uppercase mb-1">
{icon}{label}
</div>
);
}
function WeightBadge({ weight }: { weight: string }) {
const cls = weight === 'CRITICAL' ? 'bg-red-500/20 text-red-400' : weight === 'HIGH' ? 'bg-orange-500/20 text-orange-400' : weight === 'ELEVATED' ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-800 text-slate-500';
return <span className={`text-[7px] px-1.5 py-0.5 rounded font-black ${cls}`}>{weight}</span>;
}
function DetailBox({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-slate-900/60 border border-slate-800/80 rounded-lg p-2.5">
<span className="block text-[8px] text-slate-600 tracking-widest uppercase mb-1">{label}</span>
<span className="text-[11px] font-bold text-slate-200">{value}</span>
</div>
);
}
function ToggleRow({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<div className="flex justify-between items-center cursor-pointer p-1.5 rounded hover:bg-white/5 transition-colors" onClick={onClick}>
<span className="text-[10px] text-slate-400">{label}</span>
<div className={`w-7 h-3.5 rounded-full p-0.5 transition-colors ${active ? 'bg-sky-500' : 'bg-slate-700'}`}>
<div className={`w-2.5 h-2.5 bg-white rounded-full transition-transform shadow-sm ${active ? 'translate-x-3.5' : ''}`}/>
</div>
</div>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+255
View File
@@ -0,0 +1,255 @@
import React, { useEffect, useRef, useState } from 'react';
import Globe from 'globe.gl';
import * as THREE from 'three';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { TileTextureManager } from '../utils/tileRenderer';
interface Props {
data: { planes: any[]; satellites: any[]; news?: any[]; gpsInterference?: any[]; webcams?: any[]; ships?: any[]; aiAnalysis?: any; emergencySquawks?: any[]; conflicts?: any[]; cyber_attacks?: any[]; };
selectedItem: any;
onSelectItem: (item: any) => void;
onPovChange?: (pov: { lat: number; lng: number; altitude: number }) => void;
}
const DEG_TO_RAD = Math.PI / 180;
const GLOBE_RADIUS = 100;
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
function predictPlanePos(p: any, s: number) {
if (!p.velocity || p.heading == null || s <= 0 || s > 120) return p;
const cLat = Math.cos(p.lat * DEG_TO_RAD); if (Math.abs(cLat) < 0.001) return p;
return { ...p, lat: p.lat + (p.velocity * Math.cos(p.heading * DEG_TO_RAD) * s) / 111320, lon: (p.lon ?? p.lng) + (p.velocity * Math.sin(p.heading * DEG_TO_RAD) * s) / (111320 * cLat) };
}
function predictShipPos(p: any, s: number) {
if (!p.velocity || p.heading == null || s <= 0 || s > 600) return p;
const cLat = Math.cos(p.lat * DEG_TO_RAD); if (Math.abs(cLat) < 0.001) return p;
return { ...p, lat: p.lat + (p.velocity * Math.cos(p.heading * DEG_TO_RAD) * s) / 111320, lon: (p.lon ?? p.lng) + (p.velocity * Math.sin(p.heading * DEG_TO_RAD) * s) / (111320 * cLat) };
}
function predictSatPos(p: any, s: number) {
if (!isFinite(p.lat) || !isFinite(p.lon ?? p.lng)) return p;
return { ...p, lon: ((p.lon ?? p.lng) + (0.067 * s) + 180) % 360 - 180 };
}
function polar2xyz(lat: number, lng: number, alt: number): [number, number, number] {
const phi = (90 - lat) * DEG_TO_RAD, theta = (90 - lng) * DEG_TO_RAD, r = GLOBE_RADIUS * (1 + alt);
return [r * Math.sin(phi) * Math.cos(theta), r * Math.cos(phi), r * Math.sin(phi) * Math.sin(theta)];
}
function validCoord(la: any, lo: any) { return typeof la === 'number' && typeof lo === 'number' && isFinite(la) && isFinite(lo); }
function hexGeoJson(lat: number, lon: number, radiusDeg: number) {
const cosLat = Math.cos((lat * Math.PI) / 180); const verts: [number, number][] = [];
for (let i = 0; i < 6; i++) { const ang = ((i * 60 - 90) * Math.PI) / 180; verts.push([lon + (radiusDeg / cosLat) * Math.sin(ang), lat + radiusDeg * Math.cos(ang)]); }
verts.push(verts[0]); return { type: 'Polygon' as const, coordinates: [verts] };
}
function createPlaneGeo() { const geo = new THREE.ConeGeometry(0.55, 2.2, 3); geo.rotateX(Math.PI / 2); return geo; }
function createShipGeo() { return new THREE.BoxGeometry(0.5, 0.35, 1.8); }
function createSatGeo() { return new THREE.OctahedronGeometry(0.75); }
// Altitude-coded aircraft colors (WorldView style)
function getPlaneColor(item: any): [number, number, number] {
if (item.military) return [1.0, 0.15, 0.05]; // red — military
const alt = item.alt || 0;
if (alt > 10000) return [0.0, 0.85, 1.0]; // cyan — high altitude
if (alt > 5000) return [0.2, 1.0, 0.5]; // green — cruise
if (alt > 2000) return [1.0, 0.7, 0.0]; // amber — low
if (alt > 500) return [1.0, 0.4, 0.0]; // orange — very low
return [1.0, 1.0, 1.0]; // white — ground/unknown
}
function getShipColor(item: any): [number, number, number] {
const t = (item.type || item.vessel_type || '').toLowerCase();
if (item.military || t.includes('military') || t.includes('naval')) return [1.0, 0.2, 0.1];
if (t.includes('tanker') || t.includes('lng')) return [1.0, 0.65, 0.0];
return [0.0, 0.7, 1.0];
}
const GlobeView: React.FC<Props> = ({ data, selectedItem, onSelectItem, onPovChange }) => {
const globeRef = useRef<HTMLDivElement>(null), globeInstance = useRef<any>(null), mapContainerRef = useRef<HTMLDivElement>(null);
const [isLoaded, setIsLoaded] = useState(false), [viewMode, setViewMode] = useState<'globe' | 'map'>('globe'), [mapReady, setMapReady] = useState(false);
const viewModeRef = useRef<'globe' | 'map'>('globe');
const MAX_PLANES = 20000, MAX_SHIPS = 10000, MAX_SATS = 3000;
const pRef = useRef<any[]>([]), sRef = useRef<any[]>([]), saRef = useRef<any[]>([]), spRef = useRef<any[]>([]), ssRef = useRef<any[]>([]);
const pT = useRef(Date.now()), sT = useRef(Date.now()), saT = useRef(Date.now());
const pM = useRef<THREE.InstancedMesh>(null!), sM = useRef<THREE.InstancedMesh>(null!), saM = useRef<THREE.InstancedMesh>(null!), selM = useRef<THREE.Mesh>(null!);
const currentPov = useRef({ lat: 25, lng: 20, altitude: 2.2 });
const mapLibre = useRef<maplibregl.Map | null>(null);
useEffect(() => { pRef.current = data.planes || []; pT.current = Date.now(); }, [data.planes]);
useEffect(() => { sRef.current = data.ships || []; sT.current = Date.now(); }, [data.ships]);
useEffect(() => { saRef.current = data.satellites || []; saT.current = Date.now(); }, [data.satellites]);
useEffect(() => {
viewModeRef.current = viewMode;
// When returning to globe from map, reset altitude so controls
// don't immediately re-trigger the map condition (altitude < 0.35)
if (viewMode === 'globe' && globeInstance.current) {
const safeAlt = 1.5;
currentPov.current = { ...currentPov.current, altitude: safeAlt };
globeInstance.current.pointOfView({ lat: currentPov.current.lat, lng: currentPov.current.lng, altitude: safeAlt }, 800);
}
}, [viewMode]);
useEffect(() => {
(window as any).__GODS_EYE_ON_SECTOR_DATA__ = (msg: any) => { spRef.current = msg.planes || []; ssRef.current = msg.ships || []; };
return () => { (window as any).__GODS_EYE_ON_SECTOR_DATA__ = null; };
}, []);
useEffect(() => {
if (!isLoaded || viewMode !== 'globe') return;
const interval = setInterval(() => {
const pov = currentPov.current;
if (pov.altitude > 1.2) { spRef.current = []; ssRef.current = []; return; }
const span = pov.altitude * 45;
(window as any).__GODS_EYE_REQUEST_SECTOR__?.({ lamin: Math.max(-90, pov.lat-span), lamax: Math.min(90, pov.lat+span), lomin: pov.lng-span*1.5, lomax: pov.lng+span*1.5 });
}, 2500);
return () => clearInterval(interval);
}, [isLoaded, viewMode]);
useEffect(() => {
if (!globeRef.current || globeInstance.current) return;
const tileMgr = new TileTextureManager();
const globe = new Globe(globeRef.current).width(window.innerWidth).height(window.innerHeight).backgroundColor('#020617').showGlobe(true)
.globeMaterial(new THREE.MeshStandardMaterial({ map: tileMgr.createTexture(), roughness: 0.9, metalness: 0.2 }))
.showAtmosphere(true).atmosphereColor('#0ea5e9').atmosphereAltitude(0.15);
globe.pointOfView({ lat: 25, lng: 20, altitude: 2.2 }, 0); globeInstance.current = globe;
const scene = globe.scene(); scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 3.0); sun.position.set(500, 500, 500); scene.add(sun);
const matPlane = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.2, metalness: 0.9, emissive: 0xffffff, emissiveIntensity: 0.4 });
const matShip = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.3, metalness: 0.7, emissive: 0xffffff, emissiveIntensity: 0.35 });
const matSat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.1, metalness: 1.0, emissive: 0xffffff, emissiveIntensity: 0.5 });
pM.current = new THREE.InstancedMesh(createPlaneGeo(), matPlane, MAX_PLANES);
sM.current = new THREE.InstancedMesh(createShipGeo(), matShip, MAX_SHIPS);
saM.current = new THREE.InstancedMesh(createSatGeo(), matSat, MAX_SATS);
[pM.current, sM.current, saM.current].forEach((m) => { m.instanceMatrix.setUsage(THREE.DynamicDrawUsage); m.frustumCulled = false; scene.add(m); });
selM.current = new THREE.Mesh(new THREE.RingGeometry(1.5, 1.8, 32), new THREE.MeshBasicMaterial({ color: 0x00ffff, side: THREE.DoubleSide, transparent: true, opacity: 0.9 }));
selM.current.visible = false; scene.add(selM.current);
globe.controls().addEventListener('change', () => {
const pov = globe.pointOfView(); currentPov.current = pov;
onPovChange?.(pov);
if (pov.altitude < 0.35 && viewModeRef.current === 'globe') setViewMode('map');
});
const getHits = (e: PointerEvent) => {
const rect = globeRef.current!.getBoundingClientRect(), mouse = new THREE.Vector2(((e.clientX - rect.left)/rect.width)*2-1, -((e.clientY-rect.top)/rect.height)*2+1);
const rc = new THREE.Raycaster(); rc.setFromCamera(mouse, globe.camera());
return rc.intersectObjects([pM.current, sM.current, saM.current]);
};
globeRef.current!.addEventListener('pointerup', (e) => {
const hits = getHits(e); if (hits.length > 0 && hits[0].instanceId != null) (window as any).__GODS_EYE_HIT__ = { obj: hits[0].object, idx: hits[0].instanceId };
});
tileMgr.loadBaseLayer().catch(()=>{}); setIsLoaded(true);
return () => { if (typeof globe._destructor === 'function') globe._destructor(); globeInstance.current = null; };
}, []);
useEffect(() => {
if (!isLoaded || !globeInstance.current) return;
const dummy = new THREE.Object3D(), up = new THREE.Vector3(0, 1, 0), basis = new THREE.Matrix4();
const rendered: {p: any[], s: any[], sa: any[]} = { p: [], s: [], sa: [] };
function animate() {
if (viewModeRef.current !== 'globe') { requestAnimationFrame(animate); return; }
const t = Date.now(), pe = (t-pT.current)/1000, se = (t-sT.current)/1000, sae = (t-saT.current)/1000, alt = currentPov.current.altitude;
const planes = [...pRef.current]; if(spRef.current.length) { const ids = new Set(planes.map(x=>x.id)); spRef.current.forEach(x=>{if(!ids.has(x.id)) planes.push(x);}); }
const ships = [...sRef.current]; if(ssRef.current.length) { const ids = new Set(ships.map(x=>x.id)); ssRef.current.forEach(x=>{if(!ids.has(x.id)) ships.push(x);}); }
const sats = saRef.current;
const lod = alt > 1.8 ? 10 : alt > 1.0 ? 3 : 1, isZ = alt < 1.2, pLat = currentPov.current.lat, pLng = currentPov.current.lng;
[ { m: pM.current, d: planes, t: pe, lim: MAX_PLANES, type: 'p', f: predictPlanePos, r: rendered.p },
{ m: sM.current, d: ships, t: se, lim: MAX_SHIPS, type: 's', f: predictShipPos, r: rendered.s },
{ m: saM.current, d: sats, t: sae, lim: MAX_SATS, type: 'sa', f: predictSatPos, r: rendered.sa }
].forEach(l => {
if (!l.m) return;
let count = 0; l.r.length = 0;
for (let i = 0; i < l.d.length && count < l.lim; i++) {
const item = l.d[i], lat = item.lat, lon = item.lon ?? item.lng;
if (!validCoord(lat, lon)) continue;
if (isZ && l.type !== 'sa' && (Math.abs(lat - pLat) > 60 || Math.abs(((lon - pLng + 540) % 360) - 180) > 70)) continue;
if (l.type !== 'sa' && !(item.military || item.vessel_type === 'Military') && (i % lod !== 0)) continue;
const p = l.f(item, l.t), [x, y, z] = polar2xyz(p.lat, p.lon ?? p.lng, l.type === 'sa' ? 0.15 : 0.02);
if (y === -1000) continue;
const pos = new THREE.Vector3(x, y, z), norm = pos.clone().normalize();
basis.makeBasis(new THREE.Vector3().crossVectors(up, norm).normalize(), new THREE.Vector3().crossVectors(norm, new THREE.Vector3().crossVectors(up, norm).normalize()).normalize(), norm);
dummy.quaternion.setFromRotationMatrix(basis); dummy.position.copy(pos); dummy.rotateZ(-(item.heading || 0) * DEG_TO_RAD);
const s = l.type === 'sa' ? 1.4 : (alt > 2.0 ? 1.6 : alt > 1.0 ? 1.2 : alt < 0.4 ? 0.6 : 0.9); dummy.scale.set(s, s, s);
dummy.updateMatrix(); l.m.setMatrixAt(count, dummy.matrix);
const c = l.type === 'p' ? getPlaneColor(item) : l.type === 's' ? getShipColor(item) : [0, 1, 0.5];
l.m.setColorAt(count, new THREE.Color(c[0], c[1], c[2])); l.r[count] = item; count++;
}
l.m.instanceMatrix.needsUpdate = true; if (l.m.instanceColor) l.m.instanceColor.needsUpdate = true; l.m.count = count;
});
if (selectedItem && validCoord(selectedItem.lat, selectedItem.lon ?? selectedItem.lng)) {
const [x, y, z] = polar2xyz(selectedItem.lat, selectedItem.lon ?? selectedItem.lng, selectedItem.dataType === 'satellite' ? 0.16 : 0.03);
selM.current.position.set(x, y, z); selM.current.lookAt(0, 0, 0); selM.current.visible = true;
} else selM.current.visible = false;
const hit = (window as any).__GODS_EYE_HIT__;
if (hit) {
if (hit.obj === pM.current) onSelectItem({ ...rendered.p[hit.idx], dataType: 'plane' });
else if (hit.obj === sM.current) onSelectItem({ ...rendered.s[hit.idx], dataType: 'ship' });
else if (hit.obj === saM.current) onSelectItem({ ...rendered.sa[hit.idx], dataType: 'satellite' });
(window as any).__GODS_EYE_HIT__ = null;
}
requestAnimationFrame(animate);
}
animate();
}, [isLoaded]);
useEffect(() => {
if (!globeInstance.current || !isLoaded) return;
const globe = globeInstance.current;
globe.arcsData(data.cyber_attacks || []).arcStartLat('startLat').arcStartLng('startLng').arcEndLat('endLat').arcEndLng('endLng').arcColor((d: any) => d.color || '#ff003c').arcDashLength(0.5).arcDashGap(1).arcDashAnimateTime(1000).arcStroke(0.6).arcAltitudeAutoScale(0.3);
const rData = [...(data.news || []).filter(n => n.lat).map(n => ({ ...n, _ringType: 'news' })), ...(data.emergencySquawks || []).filter(e => e.lat).map(e => ({ ...e, _ringType: 'emergency' }))];
globe.ringsData(rData).ringLat('lat').ringLng((d: any) => d.lon ?? d.lng).ringColor((d: any) => d._ringType === 'emergency' ? (t: number) => `rgba(255,0,50,${1-t})` : (t: number) => `rgba(14,165,233,${0.4*(1-t)})`).ringMaxRadius(4);
globe.polygonsData((data.gpsInterference || []).map(z => ({ ...z, _geo: hexGeoJson(z.lat, z.lon ?? z.lng, 1.2) }))).polygonGeoJsonGeometry((d: any) => d._geo).polygonCapColor(() => 'rgba(255,0,60,0.15)').polygonStrokeColor(() => '#ff003c').polygonAltitude(0.001);
}, [data.news, data.gpsInterference, data.cyber_attacks, isLoaded]);
// --- MAPLIBRE (2D) ---
useEffect(() => {
if (viewMode !== 'map' || !mapContainerRef.current) {
setMapReady(false);
if (mapLibre.current) { mapLibre.current.remove(); mapLibre.current = null; }
return;
}
const map = new maplibregl.Map({ container: mapContainerRef.current, style: MAP_STYLE, center: [currentPov.current.lng, currentPov.current.lat], zoom: 9 });
map.on('zoom', () => { if (map.getZoom() < 4) setViewMode('globe'); });
map.on('moveend', () => { const c = map.getCenter(); currentPov.current = { ...currentPov.current, lat: c.lat, lng: c.lng }; });
map.on('load', () => {
map.addSource('p', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'p-layer', type: 'circle', source: 'p', paint: { 'circle-radius': 6, 'circle-color': '#eab308', 'circle-stroke-width': 1, 'circle-stroke-color': '#000' } });
map.addSource('s', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 's-layer', type: 'circle', source: 's', paint: { 'circle-radius': 5, 'circle-color': '#0ea5e9', 'circle-stroke-width': 1, 'circle-stroke-color': '#000' } });
map.addSource('sa', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'sa-layer', type: 'circle', source: 'sa', paint: { 'circle-radius': 4, 'circle-color': '#22c55e', 'circle-stroke-width': 1, 'circle-stroke-color': '#000' } });
map.on('click', 'p-layer', (e) => { if (e.features?.length) onSelectItem({ ...e.features[0].properties, dataType: 'plane' }); });
map.on('click', 's-layer', (e) => { if (e.features?.length) onSelectItem({ ...e.features[0].properties, dataType: 'ship' }); });
map.on('click', 'sa-layer', (e) => { if (e.features?.length) onSelectItem({ ...e.features[0].properties, dataType: 'satellite' }); });
setMapReady(true);
});
mapLibre.current = map;
return () => { setMapReady(false); if (mapLibre.current) { mapLibre.current.remove(); mapLibre.current = null; } };
}, [viewMode]);
useEffect(() => {
const map = mapLibre.current;
if (!mapReady || !map) return;
const pGeo: any = { type: 'FeatureCollection', features: (data.planes || []).filter(x => validCoord(x.lat, x.lon)).map(x => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [x.lon, x.lat] }, properties: { ...x } })) };
const sGeo: any = { type: 'FeatureCollection', features: (data.ships || []).filter(x => validCoord(x.lat, x.lon??x.lng)).map(x => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [x.lon??x.lng, x.lat] }, properties: { ...x } })) };
const saGeo: any = { type: 'FeatureCollection', features: (data.satellites || []).filter(x => validCoord(x.lat, x.lon??x.lng)).map(x => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [x.lon??x.lng, x.lat] }, properties: { ...x } })) };
(map.getSource('p') as any)?.setData(pGeo);
(map.getSource('s') as any)?.setData(sGeo);
(map.getSource('sa') as any)?.setData(saGeo);
}, [mapReady, data.planes, data.ships, data.satellites]);
return (
<div className="absolute inset-0 bg-[#020617]">
<div ref={globeRef} style={{ width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, visibility: viewMode === 'globe' ? 'visible' : 'hidden', pointerEvents: viewMode === 'globe' ? 'auto' : 'none' }} />
<div ref={mapContainerRef} style={{ width: '100%', height: '100%', position: 'absolute', top: 0, zIndex: 10, display: viewMode === 'map' ? 'block' : 'none' }} />
{viewMode === 'map' && (
<button
onClick={() => setViewMode('globe')}
style={{ position: 'absolute', bottom: '48px', right: '12px', zIndex: 20 }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-900/90 border border-sky-500/50 text-sky-400 text-[10px] font-bold tracking-wider rounded-lg backdrop-blur-sm hover:bg-sky-500/20 transition-all shadow-lg"
>
3D Globe
</button>
)}
</div>
);
};
export default React.memo(GlobeView);
+86
View File
@@ -0,0 +1,86 @@
@import "tailwindcss";
:root {
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #000;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}
#root {
width: 100%;
height: 100vh;
}
.backdrop-blur-md {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.backdrop-blur-xl {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.backdrop-blur-2xl {
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
}
/* Scrollbar styling */
.scrollbar-thin::-webkit-scrollbar {
width: 3px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(100, 200, 255, 0.15);
border-radius: 2px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: rgba(100, 200, 255, 0.3);
}
/* Globe HTML marker animations */
.globe-marker {
transition: opacity 0.3s ease;
}
.globe-marker:hover {
z-index: 100 !important;
}
.globe-marker:hover img {
transform: scale(1.3);
transition: transform 0.2s ease;
}
.globe-marker-conflict {
animation: conflictPulse 2s ease-in-out infinite;
}
.globe-marker-launch {
animation: launchGlow 3s ease-in-out infinite;
}
@keyframes conflictPulse {
0%, 100% { opacity: 0.9; }
50% { opacity: 0.6; }
}
@keyframes launchGlow {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.3); }
}
+41
View File
@@ -0,0 +1,41 @@
import { Component, StrictMode, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
class RootErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.error('[UI] Fatal render error:', error);
}
render() {
if (this.state.hasError) {
return (
<div style={{ height: '100vh', display: 'grid', placeItems: 'center', background: '#000', color: '#93c5fd', fontFamily: 'monospace' }}>
<div style={{ textAlign: 'center' }}>
<p style={{ margin: 0, fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.08em' }}>UI Recovery Mode</p>
<p style={{ margin: '8px 0 0', fontSize: '11px', color: '#9ca3af' }}>Open devtools console for the runtime stack trace.</p>
</div>
</div>
);
}
return this.props.children;
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RootErrorBoundary>
<App />
</RootErrorBoundary>
</StrictMode>,
)
+215
View File
@@ -0,0 +1,215 @@
import * as THREE from 'three';
// CartoDB Dark Matter — high-performance tactical dark tiles
const IMG = 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
// OpenStreetMap Roads (monochrome overlay style)
const RDS = 'https://a.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}.png';
function tileYToLat(y: number, z: number): number {
const n = 1 << z;
return Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * (180 / Math.PI);
}
function latToTileY(lat: number, z: number): number {
const n = 1 << z;
const r = lat * (Math.PI / 180);
return Math.floor(
((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * n,
);
}
/**
* Simple tile texture for the 3D globe.
* Uses ESRI satellite imagery with road overlay.
* Deep zoom detail is handled by switching to a 2D Leaflet map.
*/
export class TileTextureManager {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private loaded = new Set<string>();
private loading = new Set<string>();
private texture: THREE.CanvasTexture | null = null;
private disposed = false;
private dirty = false;
private rafId = 0;
private W: number;
private H: number;
private lastArea = { lat: 0, lon: 0, z: 0 };
constructor() {
// 4k is the "sweet spot" for globe textures — 8k often hangs the main thread on GPU upload
this.W = 4096;
this.H = 2048;
this.canvas = document.createElement('canvas');
this.canvas.width = this.W;
this.canvas.height = this.H;
const ctx = this.canvas.getContext('2d', { alpha: false });
if (!ctx) throw new Error('Canvas 2D unavailable');
this.ctx = ctx;
this.ctx.fillStyle = '#050a15';
this.ctx.fillRect(0, 0, this.W, this.H);
}
createTexture(): THREE.CanvasTexture {
this.texture = new THREE.CanvasTexture(this.canvas);
this.texture.colorSpace = THREE.SRGBColorSpace;
this.texture.minFilter = THREE.LinearMipmapLinearFilter;
this.texture.magFilter = THREE.LinearFilter;
this.texture.anisotropy = 4;
this.startDirtyLoop();
return this.texture;
}
async loadBaseLayer(): Promise<void> {
const z = 3;
const n = 1 << z;
const b: Promise<void>[] = [];
for (let x = 0; x < n; x++)
for (let y = 0; y < n; y++) b.push(this.fetch(x, y, z, IMG));
await Promise.allSettled(b);
this.dirty = true;
}
async updateForView(alt: number, lat: number, lon: number): Promise<void> {
let tz: number;
if (alt > 2.5) tz = 3;
else if (alt > 1.5) tz = 4;
else if (alt > 0.8) tz = 5;
else if (alt > 0.4) tz = 6;
else if (alt > 0.18) tz = 7;
else if (alt > 0.08) tz = 8;
else tz = 9;
const p = this.lastArea;
if (tz === p.z && Math.abs(lat - p.lat) < 2 && Math.abs(lon - p.lon) < 2)
return;
this.lastArea = { lat, lon, z: tz };
if (tz <= 3) return;
const r = Math.max(alt * 50, 15);
await this.loadArea(lat, lon, r, tz, IMG);
if (tz >= 5) await this.loadArea(lat, lon, r, Math.min(tz, 8), RDS);
}
private async loadArea(
cLat: number,
cLon: number,
r: number,
z: number,
url: string,
): Promise<void> {
const n = 1 << z;
const minLat = Math.max(-85, cLat - r);
const maxLat = Math.min(85, cLat + r);
const yMin = Math.max(0, latToTileY(maxLat, z));
const yMax = Math.min(n - 1, latToTileY(minLat, z));
const xMin = Math.floor(((cLon - r + 180) / 360) * n);
const xMax = Math.floor(((cLon + r + 180) / 360) * n);
const tiles: { x: number, y: number, dist: number }[] = [];
// Calculate center tile coords to sort by distance
const cx = ((Math.floor(((cLon + 180) / 360) * n) % n) + n) % n;
const cy = latToTileY(cLat, z);
for (let tx = xMin; tx <= xMax; tx++) {
const x = ((tx % n) + n) % n;
for (let y = yMin; y <= yMax; y++) {
const pre = url === IMG ? 'I' : 'R';
const key = `${pre}${z}/${x}/${y}`;
if (!this.loaded.has(key) && !this.loading.has(key)) {
// Manhattan distance for sorting
const dist = Math.abs(x - cx) + Math.abs(y - cy);
tiles.push({ x, y, dist });
}
}
}
// Sort tiles by distance from center
tiles.sort((a, b) => a.dist - b.dist);
// Process in batches of 6 (slightly smaller for better prioritization)
for (let i = 0; i < tiles.length; i += 6) {
const batch = tiles.slice(i, i + 6).map(({ x, y }) => this.fetch(x, y, z, url));
await Promise.allSettled(batch);
this.dirty = true;
}
}
private async fetch(x: number, y: number, z: number, base: string): Promise<void> {
const pre = base === IMG ? 'I' : 'R';
const key = `${pre}${z}/${x}/${y}`;
if (this.loaded.has(key) || this.loading.has(key)) return Promise.resolve();
this.loading.add(key);
const url = base
.replace('{z}', String(z))
.replace('{y}', String(y))
.replace('{x}', String(x));
try {
const cache = await caches.open('tile-cache-v1');
let response = await cache.match(url);
if (!response) {
response = await fetch(url);
if (response.ok) {
await cache.put(url, response.clone());
}
}
if (!response.ok) throw new Error(`${response.status}`);
const blob = await response.blob();
const bmp = await createImageBitmap(blob);
if (!this.disposed) {
this.drawBitmap(bmp, x, y, z);
this.loaded.add(key);
}
bmp.close();
this.loading.delete(key);
} catch (err) {
this.loading.delete(key);
// Fallback: if cache fails, try fetching directly
if (!this.disposed) {
// Silently fail or retry without cache
}
}
}
private drawBitmap(bmp: ImageBitmap, x: number, y: number, z: number): void {
const n = 1 << z;
const lonL = (x / n) * 360 - 180;
const lonR = ((x + 1) / n) * 360 - 180;
const latT = tileYToLat(y, z);
const latB = tileYToLat(y + 1, z);
const px0 = Math.round(((lonL + 180) / 360) * this.W);
const px1 = Math.round(((lonR + 180) / 360) * this.W);
const py0 = Math.round(((90 - latT) / 180) * this.H);
const py1 = Math.round(((90 - latB) / 180) * this.H);
this.ctx.drawImage(bmp, px0, py0, px1 - px0 + 1, py1 - py0 + 1);
}
private startDirtyLoop(): void {
let lastCheck = 0;
const tick = (now: number) => {
if (this.disposed) return;
// Throttle texture uploads to 5fps max (every 200ms) — uploading an 8k texture is expensive
if (this.dirty && this.texture && now - lastCheck > 200) {
this.texture.needsUpdate = true;
this.dirty = false;
lastCheck = now;
}
this.rafId = requestAnimationFrame(tick);
};
this.rafId = requestAnimationFrame(tick);
}
dispose(): void {
this.disposed = true;
cancelAnimationFrame(this.rafId);
this.loaded.clear();
this.loading.clear();
if (this.texture) { this.texture.dispose(); this.texture = null; }
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
build: {
rollupOptions: {
output: {
manualChunks: {
three: ['three'],
'globe-gl': ['globe.gl'],
'maplibre-gl': ['maplibre-gl'],
},
},
},
},
})