Compare commits

..

42 Commits

Author SHA1 Message Date
anoracleofra-code 686e304358 merge: resolve conflicts with Podman compose PR
Former-commit-id: 1cf7a31a63
2026-03-09 17:48:22 -06:00
anoracleofra-code 8cddf6794d feat: v0.4 — satellite imagery, KiwiSDR radio, LOCATE bar & security cleanup
New features:
- NASA GIBS (MODIS Terra) daily satellite imagery with 30-day time slider
- Esri World Imagery high-res satellite layer (sub-meter, zoom 18+)
- KiwiSDR SDR receivers on map with embedded radio tuner
- Sentinel-2 intel card — right-click for recent satellite photo popup
- LOCATE bar — search by coordinates or place name (Nominatim geocoding)
- SATELLITE style preset in bottom bar cycling
- v0.4 changelog modal on first launch

Fixes:
- Satellite imagery renders below data icons (imagery-ceiling anchor)
- Sentinel-2 opens full-res PNG directly (not STAC catalog JSON)
- Light/dark theme: UI stays dark, only map basemap changes

Security:
- Removed test files with hardcoded API keys from tracking
- Removed .git_backup directory from tracking
- Updated .gitignore to exclude test files, dev scripts, cache files

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

Former-commit-id: e89e992293
2026-03-09 17:46:33 -06:00
Shadowbroker a98f46c708 Merge pull request #23 from ttulttul/codex/podman-compose-and-css-fixes
Add Podman compose support and fix frontend production CSS

Former-commit-id: ec3296f23a
2026-03-09 15:38:57 -06:00
Ken Simpson d6f97df336 Add Podman compose support and fix frontend production CSS
Former-commit-id: f197ec0f20
2026-03-09 11:51:37 -07:00
anoracleofra-code 91a63cf17a docs: update description to multi-domain OSINT instead of full-spectrum geospatial
Former-commit-id: 85748a6fea
2026-03-09 09:30:31 -06:00
anoracleofra-code 354ed37e1a fix: start scripts now validate prerequisites and stop on failure
- Check for Python and Node.js before starting
- Stop with clear error message if pip install fails
- Recommend Python 3.10-3.12 (3.13+ has compatibility issues)
- Show version info at startup for easier debugging
- Updated README with Python version guidance


Former-commit-id: 28f92f1cb9
2026-03-09 09:25:57 -06:00
anoracleofra-code 3c18bef174 fix: add legacy-cgi dependency for Python 3.13+ compatibility
Python 3.13 deprecated and 3.14 removed the cgi module entirely.
feedparser imports it, causing ModuleNotFoundError on newer Python.


Former-commit-id: 14dc1a714d
2026-03-09 08:45:40 -06:00
anoracleofra-code 09c2d3d810 fix: only show flight trail for selected no-route aircraft, not all planes
Former-commit-id: c9400785a4
2026-03-09 08:14:42 -06:00
anoracleofra-code 2e53d6d7af fix: show pip install errors in start.bat and loosen dependency pins for Python 3.13+
start.bat was silently swallowing pip errors. Strict version pins on
pydantic, fastapi, and uvicorn caused build failures on Python 3.13+
due to missing pre-built wheels.


Former-commit-id: 7b4e907bd6
2026-03-09 07:55:18 -06:00
anoracleofra-code bf0da2c434 fix: create .env file if missing when saving API keys
Docker users don't have a .env file by default, so the settings
page silently failed to save keys. Now creates it automatically.


Former-commit-id: 1d0ccdd55a
2026-03-09 07:51:59 -06:00
anoracleofra-code a57c9be0cb fix: render flight trails for all no-route aircraft instead of selected only
Former-commit-id: ac995eb628
2026-03-09 07:50:39 -06:00
anoracleofra-code e82a5ae3be fix: Docker build failures — backend .dockerignore excluded package.json, frontend lock file missing hls.js
Backend: *.json glob in .dockerignore excluded package.json, causing
npm install to fail with ENOENT. Replaced with explicit exclusions.

Frontend: hls.js was added to package.json but package-lock.json was
not regenerated, causing npm ci to fail with EUSAGE sync error.

Former-commit-id: 2dcf7061d1
2026-03-09 06:30:20 -06:00
anoracleofra-code 3326c520a9 fix: include satellites in fast data endpoint payload
Satellites were missing from /api/live-data/fast response, causing
the frontend to show 0 satellites despite the backend having data.


Former-commit-id: 7605b5f3a1
2026-03-09 06:30:19 -06:00
anoracleofra-code 24e4d331fc fix: install ws module dependencies in backend Dockerfile for AIS proxy
Former-commit-id: f8c48830f5
2026-03-09 06:01:05 -06:00
anoracleofra-code c96f6ad723 fix: document requirement for permissive CORS policy
Former-commit-id: e2e1cda5cb
2026-03-09 05:56:46 -06:00
anoracleofra-code 923c80368d fix: resolve security concerns in AIS proxy, GDELT parsing, and env key validation
Former-commit-id: fad9572959
2026-03-09 05:53:15 -06:00
anoracleofra-code 30595843a0 fix: add node and curl dependencies to backend docker image
Former-commit-id: b04b6908c2
2026-03-09 05:41:43 -06:00
anoracleofra-code cef06ff809 fix: use hls.js to support HLS video streams on non-Safari browsers
Former-commit-id: 4c846bf805
2026-03-09 01:35:48 -06:00
anoracleofra-code 502359fc30 docs: update docker installation instructions for local builds
Former-commit-id: 72b2e2a198
2026-03-09 00:52:12 -06:00
anoracleofra-code 19a0ef1c70 fix: resolve Next.js docker build endpoints and handle async map icons
Former-commit-id: 6241ea44db
2026-03-09 00:41:02 -06:00
anoracleofra-code 197d37ae5a fix: remove tailwind config from dockerignore to ensure correct CSS generation in docker build
Former-commit-id: 1edb09eff5
2026-03-09 00:18:16 -06:00
anoracleofra-code 0c9d047509 fix: smooth position interpolation for planes, boats, and satellites
Former-commit-id: 2a511c628d
2026-03-08 22:32:43 -06:00
anoracleofra-code 2147eee0a6 fix: resolve shell command injection vulnerability in curl fallback
Former-commit-id: d8098c0414
2026-03-08 21:50:34 -06:00
anoracleofra-code 1298dd326b fix: implement CelesTrak fallbacks, add connection banner, and bump to v0.3.0
Former-commit-id: e7eb7c23a5
2026-03-08 21:00:59 -06:00
anoracleofra-code ed5bc5a23b fix: improve API key security, add connection banner, and bump to v0.3.0
Former-commit-id: 0c7dc37d83
2026-03-08 19:52:07 -06:00
Shadowbroker fbd64b6038 Update README.md
Former-commit-id: e7521088a0
2026-03-08 16:42:29 -06:00
anoracleofra-code 8d4403c7e6 docs: optimize README for conversion (quick start & fantasy hooks)
Former-commit-id: 8c0a62d125
2026-03-08 16:40:18 -06:00
anoracleofra-code 5e3eae0f00 fix: update frontend Dockerfile to Node 20 for Next.js 15+ support
Former-commit-id: 85e371f1b6
2026-03-08 16:23:04 -06:00
anoracleofra-code 9d58be6bbb fix: merge AI dependency additions
Former-commit-id: d69d6ad516
2026-03-08 15:46:29 -06:00
anoracleofra-code 45e6258ea4 fix: add missing sgp4, geopy, and pytz dependencies to requirements for fresh installs
Former-commit-id: 975f56f91c
2026-03-08 15:45:25 -06:00
anoracleofra-code c1f89ae446 feat: integrate AI codebase optimizations (memory safety, spatial hashing, centralized API base)
Former-commit-id: cd03bb966f
2026-03-08 15:39:33 -06:00
anoracleofra-code ff19d2bc68 fix: resolve satellite NORAD ID lookup to fix propagation loop
Former-commit-id: abbc51096b
2026-03-08 15:01:58 -06:00
anoracleofra-code 9c85e08839 fix: integrate AI cross-platform start scripts
Former-commit-id: 2054b6036d
2026-03-08 14:55:11 -06:00
anoracleofra-code c8f3812fbf bump: release v0.2.0
Former-commit-id: f7d0972057
2026-03-08 14:27:54 -06:00
anoracleofra-code ffb3041a2b fix: make test_trace.py curl commands OS-agnostic
Former-commit-id: 9949a66213
2026-03-08 14:24:36 -06:00
anoracleofra-code 775bc4adfe fix: make dev scripts cross-platform compatible
Former-commit-id: 667bede9c9
2026-03-08 14:20:28 -06:00
anoracleofra-code f9a8a998c4 feat: add Docker publishing via GitHub Actions
Former-commit-id: 38d92ac6cc
2026-03-08 14:04:52 -06:00
Shadowbroker 8c843393d1 Update README.md
Former-commit-id: d95e2f9501
2026-03-08 12:23:56 -06:00
Shadowbroker 27213cb74a Update README.md
Former-commit-id: 86f6bf19dd
2026-03-08 12:23:39 -06:00
Shadowbroker e3237dfba3 Update README.md
Former-commit-id: 0c8e8ccd82
2026-03-04 23:39:43 -07:00
Shadowbroker fa9ce48782 Update README.md
Former-commit-id: 3979c90404
2026-03-04 23:38:27 -07:00
Shadowbroker d36061976e Update README.md
Former-commit-id: 472235494e
2026-03-04 23:38:05 -07:00
82 changed files with 2884 additions and 2234 deletions
+92
View File
@@ -0,0 +1,92 @@
name: Docker Publish
on:
push:
branches: ["main"]
tags: ["v*.*.*"]
pull_request:
branches: ["main"]
env:
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-frontend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5.0.0
with:
context: ./frontend
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5.0.0
with:
context: ./backend
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+24
View File
@@ -64,3 +64,27 @@ rss_output.txt
merged.txt
tmp_fast.json
TheAirTraffic Database.xlsx
# Debug dumps & release artifacts
backend/dump.json
backend/debug_fast.json
*.zip
.git_backup/
# Test files (may contain hardcoded keys)
backend/test_*.py
backend/services/test_*.py
# Local analysis & dev tools
backend/analyze_xlsx.py
backend/xlsx_analysis.txt
backend/services/ais_cache.json
# Internal update tracking (not for repo)
updatestuff.md
# Misc dev artifacts
clean_zip.py
zip_repo.py
refactor_cesium.py
jobs.json
+201 -96
View File
@@ -2,114 +2,155 @@
<h1 align="center">🛰️ S H A D O W B R O K E R</h1>
<p align="center"><strong>Global Threat Intercept — Real-Time Geospatial Intelligence Platform</strong></p>
<p align="center">
<code>TOP SECRET // SI TK // NOFORN</code>
</p>
</p>
---
**ShadowBroker** is a real-time, full-spectrum geospatial intelligence dashboard that aggregates live data from dozens of open-source intelligence (OSINT) feeds and renders them on a unified dark-ops map interface. It tracks aircraft, ships, satellites, earthquakes, conflict zones, CCTV networks, GPS jamming, and breaking geopolitical events — all updating in real time.
![Shadowbroker1](https://github.com/user-attachments/assets/000b94eb-bf33-4e8b-8c60-15ca4a723c68)
**ShadowBroker** is a real-time, multi-domain OSINT dashboard that aggregates live data from dozens of open-source intelligence feeds and renders them on a unified dark-ops map interface. It tracks aircraft, ships, satellites, earthquakes, conflict zones, CCTV networks, GPS jamming, and breaking geopolitical events — all updating in real time.
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's designed for analysts, researchers, and enthusiasts who want a single-pane-of-glass view of global activity.
---
## Interesting Use Cases
* Track private jets of billionaires
* Monitor satellites passing overhead
* Watch naval traffic worldwide
* Detect GPS jamming zones
* Follow earthquakes and disasters in real time
---
## ⚡ Quick Start (Docker or Podman)
```bash
git clone https://github.com/BigBodyCobain/Shadowbroker.git
cd Shadowbroker
./compose.sh up -d
```
Open `http://localhost:3000` to view the dashboard! *(Requires Docker or Podman)*
`compose.sh` auto-detects `docker compose`, `docker-compose`, `podman compose`, and `podman-compose`.
If both runtimes are installed, you can force Podman with `./compose.sh --engine podman up -d`.
Do not append a trailing `.` to that command; Compose treats it as a service name.
---
## ✨ Features
### 🛩️ Aviation Tracking
- **Commercial Flights** — Real-time positions via OpenSky Network (~5,000+ aircraft)
- **Private Aircraft** — Light GA, turboprops, bizjets tracked separately
- **Private Jets** — High-net-worth individual aircraft with owner identification
- **Military Flights** — Tankers, ISR, fighters, transports via adsb.lol military endpoint
- **Flight Trail Accumulation** — Persistent breadcrumb trails for all tracked aircraft
- **Holding Pattern Detection** — Automatically flags aircraft circling (>300° total turn)
- **Aircraft Classification** — Shape-accurate SVG icons: airliners, turboprops, bizjets, helicopters
- **Grounded Detection** — Aircraft below 100ft AGL rendered with grey icons
* **Commercial Flights** — Real-time positions via OpenSky Network (~5,000+ aircraft)
* **Private Aircraft** — Light GA, turboprops, bizjets tracked separately
* **Private Jets** — High-net-worth individual aircraft with owner identification
* **Military Flights** — Tankers, ISR, fighters, transports via adsb.lol military endpoint
* **Flight Trail Accumulation** — Persistent breadcrumb trails for all tracked aircraft
* **Holding Pattern Detection** — Automatically flags aircraft circling (>300° total turn)
* **Aircraft Classification** — Shape-accurate SVG icons: airliners, turboprops, bizjets, helicopters
* **Grounded Detection** — Aircraft below 100ft AGL rendered with grey icons
### 🚢 Maritime Tracking
- **AIS Vessel Stream** — 25,000+ vessels via aisstream.io WebSocket (real-time)
- **Ship Classification** — Cargo, tanker, passenger, yacht, military vessel types with color-coded icons
- **Carrier Strike Group Tracker** — All 11 active US Navy aircraft carriers with OSINT-estimated positions
- Automated GDELT news scraping for carrier movement intelligence
- 50+ geographic region-to-coordinate mappings
- Disk-cached positions, auto-updates at 00:00 & 12:00 UTC
- **Cruise & Passenger Ships** — Dedicated layer for cruise liners and ferries
- **Clustered Display** — Ships cluster at low zoom with count labels, decluster on zoom-in
* **AIS Vessel Stream** — 25,000+ vessels via aisstream.io WebSocket (real-time)
* **Ship Classification** — Cargo, tanker, passenger, yacht, military vessel types with color-coded icons
* **Carrier Strike Group Tracker** — All 11 active US Navy aircraft carriers with OSINT-estimated positions
* Automated GDELT news scraping for carrier movement intelligence
* 50+ geographic region-to-coordinate mappings
* Disk-cached positions, auto-updates at 00:00 & 12:00 UTC
* **Cruise & Passenger Ships** — Dedicated layer for cruise liners and ferries
* **Clustered Display** — Ships cluster at low zoom with count labels, decluster on zoom-in
### 🛰️ Space & Satellites
- **Orbital Tracking** — Real-time satellite positions from N2YO API
- **Mission-Type Classification** — Color-coded by mission: military recon (red), SAR (cyan), SIGINT (white), navigation (blue), early warning (magenta), commercial imaging (green), space station (gold)
* **Orbital Tracking** — Real-time satellite positions via CelesTrak TLE data + SGP4 propagation (2,000+ active satellites, no API key required)
* **Mission-Type Classification** — Color-coded by mission: military recon (red), SAR (cyan), SIGINT (white), navigation (blue), early warning (magenta), commercial imaging (green), space station (gold)
### 🌍 Geopolitics & Conflict
- **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
- **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
- **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources
- **Region Dossier** — Right-click anywhere on the map for:
- Country profile (population, capital, languages, currencies, area)
- Head of state & government type (Wikidata SPARQL)
- Local Wikipedia summary with thumbnail
* **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources
* **Region Dossier** — Right-click anywhere on the map for:
* Country profile (population, capital, languages, currencies, area)
* Head of state & government type (Wikidata SPARQL)
* Local Wikipedia summary with thumbnail
### 🛰️ Satellite Imagery
* **NASA GIBS (MODIS Terra)** — Daily true-color satellite imagery overlay with 30-day time slider, play/pause animation, and opacity control (~250m/pixel)
* **High-Res Satellite (Esri)** — Sub-meter resolution imagery via Esri World Imagery — zoom into buildings and terrain detail (zoom 18+)
* **Sentinel-2 Intel Card** — Right-click anywhere on the map for a floating intel card showing the latest Sentinel-2 satellite photo with capture date, cloud cover %, and clickable full-resolution image (10m resolution, updated every ~5 days)
* **SATELLITE Style Preset** — Quick-toggle high-res imagery via the STYLE button (DEFAULT → SATELLITE → FLIR → NVG → CRT)
### 📻 Software-Defined Radio (SDR)
* **KiwiSDR Receivers** — 500+ public SDR receivers plotted worldwide with clustered amber markers
* **Live Radio Tuner** — Click any KiwiSDR node to open an embedded SDR tuner directly in the SIGINT panel
* **Metadata Display** — Node name, location, antenna type, frequency bands, active users
### 📷 Surveillance
- **CCTV Mesh** — 2,000+ live traffic cameras from:
- 🇬🇧 Transport for London JamCams
- 🇺🇸 Austin, TX TxDOT
- 🇺🇸 NYC DOT
- 🇸🇬 Singapore LTA
- Custom URL ingestion
- **Feed Rendering** — Automatic detection & rendering of video, MJPEG, HLS, embed, satellite tile, and image feeds
- **Clustered Map Display** — Green dots cluster with count labels, decluster on zoom
* **CCTV Mesh** — 2,000+ live traffic cameras from:
* 🇬🇧 Transport for London JamCams
* 🇺🇸 Austin, TX TxDOT
* 🇺🇸 NYC DOT
* 🇸🇬 Singapore LTA
* Custom URL ingestion
* **Feed Rendering** — Automatic detection & rendering of video, MJPEG, HLS, embed, satellite tile, and image feeds
* **Clustered Map Display** — Green dots cluster with count labels, decluster on zoom
### 📡 Signal Intelligence
- **GPS Jamming Detection** — Real-time analysis of aircraft NAC-P (Navigation Accuracy Category) values
- Grid-based aggregation identifies interference zones
- Red overlay squares with "GPS JAM XX%" severity labels
- **Radio Intercept Panel** — Scanner-style UI for monitoring communications
* **GPS Jamming Detection** — Real-time analysis of aircraft NAC-P (Navigation Accuracy Category) values
* Grid-based aggregation identifies interference zones
* Red overlay squares with "GPS JAM XX%" severity labels
* **Radio Intercept Panel** — Scanner-style UI for monitoring communications
### 🌐 Additional Layers
- **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
- **Day/Night Cycle** — Solar terminator overlay showing global daylight/darkness
- **Global Markets Ticker** — Live financial market indices (minimizable)
- **Measurement Tool** — Point-to-point distance & bearing measurement on the map
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
* **Day/Night Cycle** — Solar terminator overlay showing global daylight/darkness
* **Global Markets Ticker** — Live financial market indices (minimizable)
* **Measurement Tool** — Point-to-point distance & bearing measurement on the map
* **LOCATE Bar** — Search by coordinates (31.8, 34.8) or place name (Tehran, Strait of Hormuz) to fly directly to any location — geocoded via OpenStreetMap Nominatim
---
## 🏗️ Architecture
```
┌──────────────────────────────────────────────────────┐
FRONTEND (Next.js) │
│ │
│ ┌─────────────┐ ┌──────────┐ ┌─────────────────┐ │
│ │ MapLibre GL │ │ NewsFeed │ Control Panels │ │
│ │ 2D WebGL │ │ SIGINT │ │ Layers/Filters │ │
│ │ Map Render │ Intel │ │ Markets/Radio │ │
│ └──────┬──────┘ └────┬─────┘ └────────┬────────┘ │
│ └───────────────────────────────┘
│ │ REST API (15s fast / 60s slow
├────────────────────────┼─────────────────────────────┤
┌────────────────────────────────────────────────────────
│ FRONTEND (Next.js)
│ ┌─────────────┐ ┌──────────┐ ┌───────────────┐
│ │ MapLibre GL │ │ NewsFeed │ Control Panels│
│ │ 2D WebGL │ │ SIGINT │ │ Layers/Filters│
│ │ Map Render │ Intel │ │ Markets/Radio │
│ └──────┬──────┘ └────┬─────┘ └──────────────┘
│ └────────────────┼──────────────────┘ │
│ REST API (60s / 120s)
├──────────────────────────┼─────────────────────────────┤
│ BACKEND (FastAPI) │
│ │
│ ┌──────────────────────────────────────────────┐
│ │ Data Fetcher (Scheduler) │
│ │ ┌──────────┬──────────┬──────────┬─────────┐ │
│ │ │ OpenSky │ adsb.lol │ N2YO │ USGS │
│ │ │ Flights │ Military │ Sats │ Quakes │ │
│ │ ├──────────┼──────────┼──────────┼─────────┤ │
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV │
│ │ │ Ships │ Tracker │ Conflict │ Cameras │ │ │
│ │ ├──────────┼──────────┼──────────┼─────────┤ │
│ │ │ DeepState│ RSS │ Region │ GPS │
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │
│ │ └──────────┴──────────┴──────────┴─────────┘ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────
│ │
│ ┌───────────────────────┼──────────────────────────┐ │
│ │ Data Fetcher (Scheduler) │ │
│ │
│ │ ┌──────────┬──────────┬──────────┬───────────┐ │ │
│ │ │ OpenSky │ adsb.lol │CelesTrak │ USGS │ │
│ │ │ Flights │ Military │ Sats │ Quakes │ │
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV
│ │ │ Ships │ Tracker │ Conflict │ Cameras │ │
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
│ │ │ DeepState│ RSS │ Region │ GPS │ │
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │
│ └─────────────────────────────────────────┘
└──────────────────────────────────────────────────┘
└────────────────────────────────────────────────────────┘
```
---
@@ -121,7 +162,7 @@ Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's desig
| [OpenSky Network](https://opensky-network.org) | Commercial & private flights | ~60s | Optional (anonymous limited) |
| [adsb.lol](https://adsb.lol) | Military aircraft | ~60s | No |
| [aisstream.io](https://aisstream.io) | AIS vessel positions | Real-time WebSocket | **Yes** |
| [N2YO](https://www.n2yo.com) | Satellite orbital positions | ~60s | **Yes** |
| [CelesTrak](https://celestrak.org) | Satellite orbital positions (TLE + SGP4) | ~60s | No |
| [USGS Earthquake](https://earthquake.usgs.gov) | Global seismic events | ~60s | No |
| [GDELT Project](https://www.gdeltproject.org) | Global conflict events | ~6h | No |
| [DeepState Map](https://deepstatemap.live) | Ukraine frontline | ~30min | No |
@@ -132,18 +173,66 @@ Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's desig
| [RestCountries](https://restcountries.com) | Country profile data | On-demand (cached 24h) | No |
| [Wikidata SPARQL](https://query.wikidata.org) | Head of state data | On-demand (cached 24h) | No |
| [Wikipedia API](https://en.wikipedia.org/api) | Location summaries & aircraft images | On-demand (cached) | No |
| [NASA GIBS](https://gibs.earthdata.nasa.gov) | MODIS Terra daily satellite imagery | Daily (24-48h delay) | No |
| [Esri World Imagery](https://www.arcgis.com) | High-res satellite basemap | Static (periodically updated) | No |
| [MS Planetary Computer](https://planetarycomputer.microsoft.com) | Sentinel-2 L2A scenes (right-click) | On-demand | No |
| [KiwiSDR](https://kiwisdr.com) | Public SDR receiver locations | ~30min | No |
| [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No |
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
---
## 🚀 Getting Started
### 🐳 Docker / Podman Setup (Recommended for Self-Hosting)
The repo includes a `docker-compose.yml` that builds both images locally.
```bash
git clone https://github.com/BigBodyCobain/Shadowbroker.git
cd Shadowbroker
# Add your API keys in a repo-root .env file (optional — see Environment Variables below)
./compose.sh up -d
```
Open `http://localhost:3000` to view the dashboard.
> **Deploying publicly or on a LAN?** The frontend **auto-detects** the
> backend — it uses your browser's hostname with port `8000`
> (e.g. if you visit `http://192.168.1.50:3000`, API calls go to
> `http://192.168.1.50:8000`). **No configuration needed** for most setups.
>
> If your backend runs on a **different port or host** (reverse proxy,
> custom Docker port mapping, separate server), set `NEXT_PUBLIC_API_URL`:
>
> ```bash
> # Linux / macOS
> NEXT_PUBLIC_API_URL=http://myserver.com:9096 docker-compose up -d --build
>
> # Podman (via compose.sh wrapper)
> NEXT_PUBLIC_API_URL=http://192.168.1.50:9096 ./compose.sh up -d --build
>
> # Windows (PowerShell)
> $env:NEXT_PUBLIC_API_URL="http://myserver.com:9096"; docker-compose up -d --build
>
> # Or add to a .env file next to docker-compose.yml:
> # NEXT_PUBLIC_API_URL=http://myserver.com:9096
> ```
>
> This is a **build-time** variable (Next.js limitation) — it gets baked into
> the frontend during `npm run build`. Changing it requires a rebuild.
If you prefer to call the container engine directly, Podman users can run `podman compose up -d`, or force the wrapper to use Podman with `./compose.sh --engine podman up -d`.
Depending on your local Podman configuration, `podman compose` may still delegate to an external compose provider while talking to the Podman socket.
---
### 📦 Quick Start (No Code Required)
If you just want to run the dashboard without dealing with terminal commands:
1. Go to the **[Releases](../../releases)** tab on the right side of this GitHub page.
2. Download the `ShadowBroker_v0.1.zip` file.
2. Download the latest `.zip` file from the release.
3. Extract the folder to your computer.
4. **Windows:** Double-click `start.bat`.
**Mac/Linux:** Open terminal, type `chmod +x start.sh`, and run `./start.sh`.
@@ -157,9 +246,10 @@ If you want to modify the code or run from source:
#### Prerequisites
- **Node.js** 18+ and **npm**
- **Python** 3.10+ with `pip`
- API keys for: `aisstream.io`, `n2yo.com` (and optionally `opensky-network.org`, `lta.gov.sg`)
* **Node.js** 18+ and **npm** — [nodejs.org](https://nodejs.org/)
* **Python** 3.10, 3.11, or 3.12 with `pip` — [python.org](https://www.python.org/downloads/) (**check "Add to PATH"** during install)
* ⚠️ Python 3.13+ may have compatibility issues with some dependencies. **3.11 or 3.12 is recommended.**
* API keys for: `aisstream.io` (required), and optionally `opensky-network.org` (OAuth2), `lta.gov.sg`
### Installation
@@ -173,13 +263,12 @@ cd backend
python -m venv venv
venv\Scripts\activate # Windows
# source venv/bin/activate # macOS/Linux
pip install -r requirements.txt
pip install -r requirements.txt # includes pystac-client for Sentinel-2
# Create .env with your API keys
echo "AISSTREAM_API_KEY=your_key_here" >> .env
echo "N2YO_API_KEY=your_key_here" >> .env
echo "OPENSKY_USERNAME=your_user" >> .env
echo "OPENSKY_PASSWORD=your_pass" >> .env
echo "AIS_API_KEY=your_aisstream_key" >> .env
echo "OPENSKY_CLIENT_ID=your_opensky_client_id" >> .env
echo "OPENSKY_CLIENT_SECRET=your_opensky_secret" >> .env
# Frontend setup
cd ../frontend
@@ -195,8 +284,8 @@ npm run dev
This starts:
- **Next.js** frontend on `http://localhost:3000`
- **FastAPI** backend on `http://localhost:8000`
* **Next.js** frontend on `http://localhost:3000`
* **FastAPI** backend on `http://localhost:8000`
---
@@ -220,6 +309,9 @@ All layers are independently toggleable from the left panel:
| Ukraine Frontline | ✅ ON | Live warfront positions |
| Global Incidents | ✅ ON | GDELT conflict events |
| GPS Jamming | ✅ ON | NAC-P degradation zones |
| MODIS Terra (Daily) | ❌ OFF | NASA GIBS daily satellite imagery |
| High-Res Satellite | ❌ OFF | Esri sub-meter satellite imagery |
| KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers |
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
---
@@ -228,14 +320,14 @@ All layers are independently toggleable from the left panel:
The platform is optimized for handling massive real-time datasets:
- **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
- **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
- **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
- **Clustered Rendering** — Ships, CCTV, and earthquakes use MapLibre clustering to reduce feature count
- **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom
- **Position Interpolation** — Smooth 10s tick animation between data refreshes
- **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
- **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
* **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
* **Clustered Rendering** — Ships, CCTV, and earthquakes use MapLibre clustering to reduce feature count
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom
* **Position Interpolation** — Smooth 10s tick animation between data refreshes
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
* **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
---
@@ -255,6 +347,8 @@ live-risk-dashboard/
│ ├── geopolitics.py # GDELT + Ukraine frontline fetcher
│ ├── region_dossier.py # Right-click country/city intelligence
│ ├── radio_intercept.py # Scanner radio feed integration
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search
│ ├── network_utils.py # HTTP client with curl fallback
│ └── api_settings.py # API key management
@@ -273,6 +367,7 @@ live-risk-dashboard/
│ │ ├── MarketsPanel.tsx # Global financial markets ticker
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
│ │ ├── FindLocateBar.tsx # Search/locate bar
│ │ ├── ChangelogModal.tsx # Version changelog popup
│ │ ├── SettingsPanel.tsx # App settings
│ │ ├── ScaleBar.tsx # Map scale indicator
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
@@ -284,19 +379,29 @@ live-risk-dashboard/
## 🔑 Environment Variables
Create a `.env` file in the `backend/` directory:
### Backend (`backend/.env`)
```env
# Required
AISSTREAM_API_KEY=your_aisstream_key # Maritime vessel tracking
N2YO_API_KEY=your_n2yo_key # Satellite position data
AIS_API_KEY=your_aisstream_key # Maritime vessel tracking (aisstream.io)
# Optional (enhances data quality)
OPENSKY_CLIENT_ID=your_opensky_client_id # Higher rate limits for flight data
OPENSKY_CLIENT_SECRET=your_opensky_secret
LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras
OPENSKY_CLIENT_ID=your_opensky_client_id # OAuth2 — higher rate limits for flight data
OPENSKY_CLIENT_SECRET=your_opensky_secret # OAuth2 — paired with Client ID above
LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras
```
### Frontend (optional)
| Variable | Where to set | Purpose |
|---|---|---|
| `NEXT_PUBLIC_API_URL` | `.env` next to `docker-compose.yml`, or shell env | Override backend URL when deploying publicly or behind a reverse proxy. Leave unset for auto-detection. |
**How auto-detection works:** When `NEXT_PUBLIC_API_URL` is not set, the frontend
reads `window.location.hostname` in the browser and calls `{protocol}//{hostname}:8000`.
This means the dashboard works on `localhost`, LAN IPs, and public domains without
any configuration — as long as the backend is reachable on port 8000 of the same host.
---
## ⚠️ Disclaimer
-1
View File
@@ -1 +0,0 @@
ba57965389036194d6dd60e6de33d2e1e1bbf20b
+16
View File
@@ -0,0 +1,16 @@
venv/
__pycache__/
*.pyc
.env
.pytest_cache/
.coverage
cctv.db
*.txt
!requirements.txt
# Exclude debug/cache JSON but keep package.json and tracked_names
ais_cache.json
carrier_positions.json
dump.json
debug_fast.json
nyc_full.json
nyc_sample.json
+17
View File
@@ -2,6 +2,13 @@ FROM python:3.10-slim
WORKDIR /app
# Install Node.js (for AIS WebSocket proxy) and curl (for network fallback)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@@ -9,6 +16,16 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy source code
COPY . .
# Install Node.js dependencies (ws module for AIS WebSocket proxy)
RUN npm install --omit=dev
# Create a non-root user for security
RUN adduser --system --uid 1001 backenduser \
&& chown -R backenduser /app
# Switch to the non-root user
USER backenduser
# Expose port
EXPOSE 8000
+6 -1
View File
@@ -1,7 +1,12 @@
const WebSocket = require('ws');
const args = process.argv.slice(2);
const API_KEY = args[0] || '75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb';
const API_KEY = args[0] || process.env.AIS_API_KEY;
if (!API_KEY) {
console.error("FATAL: AIS_API_KEY is not set. WebSocket proxy cannot start.");
process.exit(1);
}
const FILTER = [
// US Aircraft Carriers and major naval groups
-112
View File
@@ -1,112 +0,0 @@
import zipfile
import xml.etree.ElementTree as ET
import re
import csv
import os
xlsx_path = r"f:\Codebase\Oracle\live-risk-dashboard\TheAirTraffic Database.xlsx"
output_path = r"f:\Codebase\Oracle\live-risk-dashboard\backend\xlsx_analysis.txt"
def parse_xlsx_sheet(z, shared_strings, sheet_num):
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
sheet_file = f'xl/worksheets/sheet{sheet_num}.xml'
if sheet_file not in z.namelist():
return []
ws_xml = z.read(sheet_file)
ws_root = ET.fromstring(ws_xml)
rows = []
for row in ws_root.findall('.//s:sheetData/s:row', ns):
cells = {}
for cell in row.findall('s:c', ns):
cell_ref = cell.get('r', '')
cell_type = cell.get('t', '')
val_elem = cell.find('s:v', ns)
val = val_elem.text if val_elem is not None else ''
if cell_type == 's' and val:
val = shared_strings[int(val)]
col = re.match(r'([A-Z]+)', cell_ref).group(1) if re.match(r'([A-Z]+)', cell_ref) else ''
cells[col] = val
rows.append(cells)
return rows
with open(output_path, 'w', encoding='utf-8') as out:
with zipfile.ZipFile(xlsx_path, 'r') as z:
shared_strings = []
if 'xl/sharedStrings.xml' in z.namelist():
ss_xml = z.read('xl/sharedStrings.xml')
root = ET.fromstring(ss_xml)
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
for si in root.findall('.//s:si', ns):
texts = si.findall('.//s:t', ns)
val = ''.join(t.text or '' for t in texts)
shared_strings.append(val)
all_entries = []
for sheet_idx in range(1, 5):
rows = parse_xlsx_sheet(z, shared_strings, sheet_idx)
if not rows:
continue
out.write(f"\n=== SHEET {sheet_idx}: {len(rows)} rows ===\n")
# Print first 5 rows
for i in range(min(5, len(rows))):
for col in sorted(rows[i].keys(), key=lambda x: (len(x), x)):
val = rows[i][col]
if val:
out.write(f" Row{i} {col}: '{val[:80]}'\n")
out.write("\n")
for r in rows[1:]:
for col, val in r.items():
val = str(val).strip()
n_regs = re.findall(r'N\d{1,5}[A-Z]{0,2}', val)
owner = r.get('B', r.get('A', '')).strip()
aircraft_type = r.get('C', r.get('D', '')).strip()
for reg in n_regs:
all_entries.append({
'registration': reg.upper(),
'owner': owner,
'type': aircraft_type,
'sheet': sheet_idx
})
unique_regs = set(e['registration'] for e in all_entries)
out.write(f"\nTOTAL ENTRIES: {len(all_entries)}\n")
out.write(f"UNIQUE REGISTRATIONS: {len(unique_regs)}\n")
csv_path = r"f:\Codebase\Oracle\live-risk-dashboard\PLANEALERTLIST\plane-alert-db-main\plane-alert-db.csv"
existing = {}
with open(csv_path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
icao = row.get('$ICAO', '').strip().upper()
reg = row.get('$Registration', '').strip().upper()
if reg:
existing[reg] = {
'icao': icao,
'category': row.get('Category', ''),
'operator': row.get('$Operator', ''),
}
already_in = unique_regs & set(existing.keys())
missing = unique_regs - set(existing.keys())
out.write(f"\nplane-alert-db: {len(existing)} registrations\n")
out.write(f"Already covered: {len(already_in)}\n")
out.write(f"MISSING: {len(missing)}\n")
out.write(f"\n--- ALREADY TRACKED ---\n")
seen = set()
for e in all_entries:
if e['registration'] in already_in and e['registration'] not in seen:
info = existing[e['registration']]
out.write(f" {e['owner'][:40]:40s} {e['registration']:10s} DB_CAT: {info['category'][:25]:25s} DB_OP: {info['operator'][:40]}\n")
seen.add(e['registration'])
out.write(f"\n--- MISSING (NEED TO ADD) ---\n")
seen = set()
for e in all_entries:
if e['registration'] in missing and e['registration'] not in seen:
out.write(f" {e['owner'][:40]:40s} {e['registration']:10s} TYPE: {e['type'][:30]}\n")
seen.add(e['registration'])
print(f"Analysis written to {output_path}")
+30 -3
View File
@@ -29,7 +29,7 @@ from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # For prototyping, allow all
allow_origins=["*"], # Must be permissive — users access from localhost, LAN IPs, Docker, custom ports
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -59,6 +59,7 @@ async def live_data_fast(request: Request):
"private_jets": d.get("private_jets", []),
"tracked_flights": d.get("tracked_flights", []),
"ships": d.get("ships", []),
"satellites": d.get("satellites", []),
"cctv": d.get("cctv", []),
"uavs": d.get("uavs", []),
"liveuamap": d.get("liveuamap", []),
@@ -91,7 +92,8 @@ async def live_data_slow(request: Request):
"frontlines": d.get("frontlines"),
"gdelt": d.get("gdelt", []),
"airports": d.get("airports", []),
"satellites": d.get("satellites", [])
"satellites": d.get("satellites", []),
"kiwisdr": d.get("kiwisdr", [])
}
# ETag based on last_updated + item counts
last_updated = d.get("last_updated", "")
@@ -112,7 +114,25 @@ async def debug_latest_data():
@app.get("/api/health")
async def health_check():
return {"status": "ok"}
import time
d = get_latest_data()
last = d.get("last_updated")
return {
"status": "ok",
"last_updated": last,
"sources": {
"flights": len(d.get("commercial_flights", [])),
"military": len(d.get("military_flights", [])),
"ships": len(d.get("ships", [])),
"satellites": len(d.get("satellites", [])),
"earthquakes": len(d.get("earthquakes", [])),
"cctv": len(d.get("cctv", [])),
"news": len(d.get("news", [])),
},
"uptime_seconds": round(time.time() - _start_time),
}
_start_time = __import__("time").time()
from services.radio_intercept import get_top_broadcastify_feeds, get_openmhz_systems, get_recent_openmhz_calls, find_nearest_openmhz_system
@@ -168,6 +188,13 @@ def api_region_dossier(lat: float, lng: float):
"""Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop."""
return get_region_dossier(lat, lng)
from services.sentinel_search import search_sentinel2_scene
@app.get("/api/sentinel2/search")
def api_sentinel2_search(lat: float, lng: float):
"""Search for latest Sentinel-2 imagery at a point. Sync for threadpool execution."""
return search_sentinel2_scene(lat, lng)
# ---------------------------------------------------------------------------
# API Settings — key registry & management
# ---------------------------------------------------------------------------
+14 -4
View File
@@ -1,10 +1,20 @@
fastapi==0.103.1
uvicorn==0.23.2
fastapi>=0.103.1
uvicorn>=0.23.2
yfinance>=0.2.40
feedparser==6.0.10
legacy-cgi>=2.6
requests==2.31.0
apscheduler==3.10.3
pydantic==2.3.0
pydantic-settings==2.0.3
pydantic>=2.3.0
pydantic-settings>=2.0.3
playwright>=1.58.0
beautifulsoup4>=4.12.0
cachetools>=5.3
cloudscraper>=1.2.71
python-dotenv>=1.0
lxml>=5.0
reverse_geocoder>=1.5
sgp4>=2.23
geopy>=2.4.0
pytz>=2023.3
pystac-client>=0.7.0
@@ -1 +0,0 @@
5d33551b09405e7e252c6a11f080a6c9eca50f6b
+10 -5
View File
@@ -14,7 +14,7 @@ import os
logger = logging.getLogger(__name__)
AIS_WS_URL = "wss://stream.aisstream.io/v0/stream"
API_KEY = os.environ.get("AIS_API_KEY", "75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb")
API_KEY = os.environ.get("AIS_API_KEY", "")
# AIS vessel type code classification
# See: https://coast.noaa.gov/data/marinecadastre/ais/VesselTypeCodes2018.pdf
@@ -211,9 +211,10 @@ def _ais_stream_loop():
"""Main loop: spawn node proxy and process messages from stdout."""
import subprocess
import os
proxy_script = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ais_proxy.js")
backoff = 1 # Exponential backoff starting at 1 second
while _ws_running:
try:
logger.info("Starting Node.js AIS Stream Proxy...")
@@ -323,8 +324,12 @@ def _ais_stream_loop():
except Exception as e:
logger.error(f"AIS proxy connection error: {e}")
if _ws_running:
logger.info("Restarting AIS proxy in 5 seconds...")
time.sleep(5)
logger.info(f"Restarting AIS proxy in {backoff}s (exponential backoff)...")
time.sleep(backoff)
backoff = min(backoff * 2, 60) # Double up to 60s max
continue
# Reset backoff on successful connection (got at least some messages)
backoff = 1
def _run_ais_loop():
+12 -3
View File
@@ -145,20 +145,29 @@ def get_api_keys():
"has_key": api["env_key"] is not None,
"env_key": api["env_key"],
"value_obfuscated": None,
"value_plain": None,
"is_set": False,
}
if api["env_key"]:
raw = os.environ.get(api["env_key"], "")
entry["value_obfuscated"] = _obfuscate(raw)
entry["value_plain"] = raw # Sent only when reveal is requested
entry["is_set"] = bool(raw)
result.append(entry)
return result
def update_api_key(env_key: str, new_value: str) -> bool:
"""Update a single key in the .env file and in the current process env."""
if not ENV_PATH.exists():
valid_keys = {api["env_key"] for api in API_REGISTRY if api.get("env_key")}
if env_key not in valid_keys:
return False
if not isinstance(new_value, str):
return False
if "\n" in new_value or "\r" in new_value:
return False
if not ENV_PATH.exists():
ENV_PATH.write_text("", encoding="utf-8")
# Update os.environ immediately
os.environ[env_key] = new_value
+162 -34
View File
@@ -72,8 +72,8 @@ class OpenSkyClient:
# User provided credentials
opensky_client = OpenSkyClient(
client_id=os.environ.get("OPENSKY_CLIENT_ID", "vancecook-api-client"),
client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", "YOUR_OPENSKY_SECRET")
client_id=os.environ.get("OPENSKY_CLIENT_ID", ""),
client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", "")
)
# Throttling and caching for OpenSky to observe the 400 req/day limit
@@ -100,7 +100,8 @@ latest_data = {
"uavs": [],
"frontlines": None,
"gdelt": [],
"liveuamap": []
"liveuamap": [],
"kiwisdr": []
}
# Thread lock for safe reads/writes to latest_data
@@ -885,9 +886,10 @@ def fetch_flights():
by_icao[id(f)] = f # no icao — keep as unique
return list(by_icao.values())
latest_data['commercial_flights'] = _merge_category(commercial, latest_data.get('commercial_flights', []))
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', []))
with _data_lock:
latest_data['commercial_flights'] = _merge_category(commercial, latest_data.get('commercial_flights', []))
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', []))
# Always write raw flights for GPS jamming analysis (nac_p field)
if flights:
@@ -964,27 +966,39 @@ def fetch_flights():
all_lists = [commercial, private_jets, private_ga, existing_tracked]
seen_hexes = set()
trail_count = 0
for flist in all_lists:
for f in flist:
count, hex_id = _accumulate_trail(f, now_ts, check_route=True)
with _trails_lock:
for flist in all_lists:
for f in flist:
count, hex_id = _accumulate_trail(f, now_ts, check_route=True)
trail_count += count
if hex_id:
seen_hexes.add(hex_id)
# Also process military flights (separate list)
for mf in latest_data.get('military_flights', []):
count, hex_id = _accumulate_trail(mf, now_ts, check_route=False)
trail_count += count
if hex_id:
seen_hexes.add(hex_id)
# Also process military flights (separate list)
for mf in latest_data.get('military_flights', []):
count, hex_id = _accumulate_trail(mf, now_ts, check_route=False)
trail_count += count
if hex_id:
seen_hexes.add(hex_id)
# Prune trails for aircraft not seen in 30 minutes
stale_cutoff = now_ts - 1800
stale_keys = [k for k, v in flight_trails.items() if v['last_seen'] < stale_cutoff]
for k in stale_keys:
del flight_trails[k]
logger.info(f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned")
# Prune stale trails (10 min for non-tracked, 30 min for tracked)
tracked_hexes = {t.get('icao24', '').lower() for t in latest_data.get('tracked_flights', [])}
stale_keys = []
for k, v in flight_trails.items():
cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 600
if v['last_seen'] < cutoff:
stale_keys.append(k)
for k in stale_keys:
del flight_trails[k]
# Enforce global cap — evict oldest trails first
if len(flight_trails) > _MAX_TRACKED_TRAILS:
sorted_keys = sorted(flight_trails.keys(), key=lambda k: flight_trails[k]['last_seen'])
evict_count = len(flight_trails) - _MAX_TRACKED_TRAILS
for k in sorted_keys[:evict_count]:
del flight_trails[k]
logger.info(f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned, {len(flight_trails)} total")
# -----------------------------------------------------------------------
# GPS / GNSS Jamming Detection — aggregate NACp from ADS-B transponders
@@ -1237,6 +1251,14 @@ def fetch_cctv():
logger.error(f"Error fetching cctv from DB: {e}")
latest_data["cctv"] = []
def fetch_kiwisdr():
try:
from services.kiwisdr_fetcher import fetch_kiwisdr_nodes
latest_data["kiwisdr"] = fetch_kiwisdr_nodes()
except Exception as e:
logger.error(f"Error fetching KiwiSDR nodes: {e}")
latest_data["kiwisdr"] = []
def fetch_bikeshare():
bikes = []
try:
@@ -1349,6 +1371,84 @@ _SAT_INTEL_DB = [
("TIANGONG", {"country": "China", "mission": "space_station", "sat_type": "Space Station", "wiki": "https://en.wikipedia.org/wiki/Tiangong_space_station"}),
]
def _parse_tle_to_gp(name, norad_id, line1, line2):
"""Convert TLE two-line element to CelesTrak GP-style dict for unified processing."""
try:
# Parse TLE line 2 fields (standard TLE format)
incl = float(line2[8:16].strip())
raan = float(line2[17:25].strip())
ecc = float("0." + line2[26:33].strip())
argp = float(line2[34:42].strip())
ma = float(line2[43:51].strip())
mm = float(line2[52:63].strip())
# Parse BSTAR from line 1 (columns 54-61)
bstar_str = line1[53:61].strip()
if bstar_str:
mantissa = float(bstar_str[:-2]) / 1e5
exponent = int(bstar_str[-2:])
bstar = mantissa * (10 ** exponent)
else:
bstar = 0.0
# Parse epoch from line 1 (columns 18-32)
epoch_yr = int(line1[18:20])
epoch_day = float(line1[20:32].strip())
year = 2000 + epoch_yr if epoch_yr < 57 else 1900 + epoch_yr
from datetime import datetime, timedelta
epoch_dt = datetime(year, 1, 1) + timedelta(days=epoch_day - 1)
return {
"OBJECT_NAME": name,
"NORAD_CAT_ID": norad_id,
"MEAN_MOTION": mm,
"ECCENTRICITY": ecc,
"INCLINATION": incl,
"RA_OF_ASC_NODE": raan,
"ARG_OF_PERICENTER": argp,
"MEAN_ANOMALY": ma,
"BSTAR": bstar,
"EPOCH": epoch_dt.strftime("%Y-%m-%dT%H:%M:%S"),
}
except Exception:
return None
def _fetch_satellites_from_tle_api():
"""Fallback: fetch satellite TLEs from tle.ivanstanojevic.me when CelesTrak is blocked."""
# Build search terms from our intel DB — deduplicate short prefixes
search_terms = set()
for key, _ in _SAT_INTEL_DB:
# Use first word for broader matching (e.g., "USA" catches USA 224, USA 245, etc.)
term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key
search_terms.add(term)
all_results = []
seen_ids = set()
for term in search_terms:
try:
url = f"https://tle.ivanstanojevic.me/api/tle/?search={term}&page_size=100&format=json"
response = fetch_with_curl(url, timeout=10)
if response.status_code != 200:
continue
data = response.json()
for member in data.get("member", []):
sat_id = member.get("satelliteId")
if sat_id in seen_ids:
continue
seen_ids.add(sat_id)
gp = _parse_tle_to_gp(
member.get("name", "UNKNOWN"),
sat_id,
member.get("line1", ""),
member.get("line2", ""),
)
if gp:
all_results.append(gp)
except Exception as e:
logger.debug(f"TLE fallback search '{term}' failed: {e}")
continue
return all_results
def fetch_satellites():
sats = []
try:
@@ -1356,16 +1456,40 @@ def fetch_satellites():
# Positions are re-propagated from cached orbital elements each cycle
now_ts = time.time()
if _sat_gp_cache["data"] is None or (now_ts - _sat_gp_cache["last_fetch"]) > 1800:
url = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json"
response = fetch_with_curl(url, timeout=15)
if response.status_code == 200:
_sat_gp_cache["data"] = response.json()
_sat_gp_cache["last_fetch"] = now_ts
logger.info(f"Satellites: Downloaded {len(_sat_gp_cache['data'])} GP records from CelesTrak")
# Try multiple CelesTrak mirrors — .org is often blocked/banned by some networks
gp_urls = [
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
]
for url in gp_urls:
try:
response = fetch_with_curl(url, timeout=8)
if response.status_code == 200:
gp_data = response.json()
if isinstance(gp_data, list) and len(gp_data) > 100:
_sat_gp_cache["data"] = gp_data
_sat_gp_cache["last_fetch"] = now_ts
logger.info(f"Satellites: Downloaded {len(gp_data)} GP records from {url}")
break
except Exception as e:
logger.warning(f"Satellites: Failed to fetch from {url}: {e}")
continue
# Fallback: if CelesTrak is blocked, use tle.ivanstanojevic.me TLE API
if _sat_gp_cache["data"] is None:
logger.info("Satellites: CelesTrak unreachable, trying TLE fallback API...")
try:
fallback_data = _fetch_satellites_from_tle_api()
if fallback_data and len(fallback_data) > 10:
_sat_gp_cache["data"] = fallback_data
_sat_gp_cache["last_fetch"] = now_ts
logger.info(f"Satellites: Got {len(fallback_data)} records from TLE fallback API")
except Exception as e:
logger.error(f"Satellites: TLE fallback also failed: {e}")
data = _sat_gp_cache["data"]
if not data:
logger.warning("No satellite GP data available")
logger.warning("No satellite GP data available from any source")
latest_data["satellites"] = sats
return
@@ -1412,7 +1536,7 @@ def fetch_satellites():
ma = s.get('MEAN_ANOMALY')
bstar = s.get('BSTAR', 0)
epoch_str = s.get('EPOCH')
norad_id = s.get('NORAD_CAT_ID', 0)
norad_id = s.get('id', 0)
if mean_motion is None or ecc is None or incl is None:
continue
@@ -1567,6 +1691,8 @@ def fetch_uavs():
cached_airports = []
flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}}
_trails_lock = threading.Lock()
_MAX_TRACKED_TRAILS = 2000 # Global cap on number of aircraft trails in memory
# (math imported at module top)
@@ -1688,6 +1814,7 @@ def update_slow_data():
fetch_cctv,
fetch_earthquakes,
fetch_geopolitics,
fetch_kiwisdr,
]
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
futures = [executor.submit(func) for func in slow_funcs]
@@ -1751,5 +1878,6 @@ def stop_scheduler():
scheduler.shutdown()
def get_latest_data():
return latest_data
with _data_lock:
return dict(latest_data)
+7 -2
View File
@@ -285,12 +285,17 @@ def fetch_global_military_incidents():
headlines = [_url_to_headline(u) for u in urls]
f["properties"]["_urls_list"] = urls
f["properties"]["_headlines_list"] = headlines
import html
# Keep html as fallback
if urls:
links = [f'<div style="margin-bottom:6px;"><a href="{u}" target="_blank">{h}</a></div>' for u, h in zip(urls, headlines)]
links = []
for u, h in zip(urls, headlines):
safe_url = u if u.startswith(('http://', 'https://')) else 'about:blank'
safe_h = html.escape(h)
links.append(f'<div style="margin-bottom:6px;"><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_h}</a></div>')
f["properties"]["html"] = ''.join(links)
else:
f["properties"]["html"] = f["properties"]["name"]
f["properties"]["html"] = html.escape(f["properties"]["name"])
f.pop("_loc_key", None)
logger.info(f"GDELT multi-file parsed: {len(features)} conflict locations from {successful} files")
+97
View File
@@ -0,0 +1,97 @@
"""
KiwiSDR public receiver list fetcher.
Scrapes the kiwisdr.com public page for active SDR receivers worldwide.
Data is embedded as HTML comments inside each entry div.
"""
import re
import logging
from cachetools import TTLCache, cached
logger = logging.getLogger(__name__)
kiwisdr_cache = TTLCache(maxsize=1, ttl=600) # 10-minute cache
def _parse_comment(html: str, field: str) -> str:
"""Extract a field value from HTML comment like <!-- field=value -->"""
m = re.search(rf'<!--\s*{field}=(.*?)\s*-->', html)
return m.group(1).strip() if m else ""
def _parse_gps(html: str):
"""Extract lat/lon from <!-- gps=(lat, lon) --> comment."""
m = re.search(r'<!--\s*gps=\(([^,]+),\s*([^)]+)\)\s*-->', html)
if m:
try:
return float(m.group(1)), float(m.group(2))
except ValueError:
return None, None
return None, None
@cached(kiwisdr_cache)
def fetch_kiwisdr_nodes() -> list[dict]:
"""Fetch and parse the KiwiSDR public receiver list."""
from services.network_utils import smart_request
try:
res = smart_request("http://kiwisdr.com/.public/", timeout=20)
if not res or res.status_code != 200:
logger.error(f"KiwiSDR fetch failed: HTTP {res.status_code if res else 'no response'}")
return []
html = res.text
# Split by entry divs
entries = re.findall(r"<div class='cl-entry[^']*'>(.*?)</div>\s*</div>", html, re.DOTALL)
nodes = []
for entry in entries:
lat, lon = _parse_gps(entry)
if lat is None or lon is None:
continue
if abs(lat) > 90 or abs(lon) > 180:
continue
offline = _parse_comment(entry, "offline")
if offline == "yes":
continue
name = _parse_comment(entry, "name") or "Unknown SDR"
users_str = _parse_comment(entry, "users")
users_max_str = _parse_comment(entry, "users_max")
bands = _parse_comment(entry, "bands")
antenna = _parse_comment(entry, "antenna")
location = _parse_comment(entry, "loc")
# Extract the URL from the href
url_match = re.search(r"href='(https?://[^']+)'", entry)
url = url_match.group(1) if url_match else ""
try:
users = int(users_str) if users_str else 0
except ValueError:
users = 0
try:
users_max = int(users_max_str) if users_max_str else 0
except ValueError:
users_max = 0
nodes.append({
"name": name[:120], # Truncate long names
"lat": round(lat, 5),
"lon": round(lon, 5),
"url": url,
"users": users,
"users_max": users_max,
"bands": bands,
"antenna": antenna[:200] if antenna else "",
"location": location[:100] if location else "",
})
logger.info(f"KiwiSDR: parsed {len(nodes)} online receivers")
return nodes
except Exception as e:
logger.error(f"KiwiSDR fetch exception: {e}")
return []
+22 -11
View File
@@ -3,10 +3,19 @@ import json
import subprocess
import shutil
import time
import requests
from urllib.parse import urlparse
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
# Reusable session with connection pooling and retry logic
_session = requests.Session()
_retry = Retry(total=2, backoff_factor=0.5, status_forcelist=[502, 503, 504])
_session.mount("https://", HTTPAdapter(max_retries=_retry, pool_maxsize=20))
_session.mount("http://", HTTPAdapter(max_retries=_retry, pool_maxsize=10))
# Find bash for curl fallback — Git bash's curl has the TLS features
# needed to pass CDN fingerprint checks (brotli, zstd, libpsl)
_BASH_PATH = shutil.which("bash") or "bash"
@@ -50,11 +59,10 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
pass # Fall through to curl below
else:
try:
import requests
if method == "POST":
res = requests.post(url, json=json_data, timeout=timeout, headers=default_headers)
res = _session.post(url, json=json_data, timeout=timeout, headers=default_headers)
else:
res = requests.get(url, timeout=timeout, headers=default_headers)
res = _session.get(url, timeout=timeout, headers=default_headers)
res.raise_for_status()
# Clear failure cache on success
_domain_fail_cache.pop(domain, None)
@@ -63,18 +71,21 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...")
_domain_fail_cache[domain] = time.time()
# Build curl command string for bash execution
header_flags = " ".join(f'-H "{k}: {v}"' for k, v in default_headers.items())
# Build curl as argument list — never pass through shell to prevent injection
_CURL_PATH = shutil.which("curl") or "curl"
cmd = [_CURL_PATH, "-s", "-w", "\n%{http_code}"]
for k, v in default_headers.items():
cmd += ["-H", f"{k}: {v}"]
if method == "POST" and json_data:
payload = json.dumps(json_data).replace('"', '\\"')
curl_cmd = f'curl -s -w "\\n%{{http_code}}" {header_flags} -X POST -H "Content-Type: application/json" -d "{payload}" "{url}"'
else:
curl_cmd = f'curl -s -w "\\n%{{http_code}}" {header_flags} "{url}"'
cmd += ["-X", "POST", "-H", "Content-Type: application/json",
"--data-binary", "@-"]
cmd.append(url)
try:
stdin_data = json.dumps(json_data) if (method == "POST" and json_data) else None
res = subprocess.run(
[_BASH_PATH, "-c", curl_cmd],
capture_output=True, text=True, timeout=timeout + 5
cmd, capture_output=True, text=True, timeout=timeout + 5,
input=stdin_data
)
if res.returncode == 0 and res.stdout.strip():
# Parse HTTP status code from -w output (last line)
+81
View File
@@ -0,0 +1,81 @@
"""
Sentinel-2 satellite imagery search via Microsoft Planetary Computer STAC API.
Free, keyless search for metadata + thumbnails. Used in the right-click dossier.
"""
import logging
from datetime import datetime, timedelta
from cachetools import TTLCache
logger = logging.getLogger(__name__)
# Cache by rounded lat/lon (0.02° grid ~= 2km), TTL 1 hour
_sentinel_cache = TTLCache(maxsize=200, ttl=3600)
def search_sentinel2_scene(lat: float, lng: float) -> dict:
"""Search for the latest Sentinel-2 L2A scene covering a point."""
cache_key = f"{round(lat, 2)}_{round(lng, 2)}"
if cache_key in _sentinel_cache:
return _sentinel_cache[cache_key]
try:
from pystac_client import Client
catalog = Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
end = datetime.utcnow()
start = end - timedelta(days=30)
search = catalog.search(
collections=["sentinel-2-l2a"],
intersects={"type": "Point", "coordinates": [lng, lat]},
datetime=f"{start.isoformat()}Z/{end.isoformat()}Z",
sortby=[{"field": "datetime", "direction": "desc"}],
max_items=3,
query={"eo:cloud_cover": {"lt": 30}},
)
items = list(search.items())
if not items:
result = {"found": False, "message": "No clear scenes in last 30 days"}
_sentinel_cache[cache_key] = result
return result
item = items[0]
# Try to sign item first for Azure blob URLs
try:
import planetary_computer
item = planetary_computer.sign_item(item)
except ImportError:
pass # planetary_computer not installed, try unsigned URLs
except Exception as e:
logger.warning(f"Sentinel-2 signing failed: {e}")
# Get the rendered_preview (full-res PNG) and thumbnail separately
rendered = item.assets.get("rendered_preview")
thumbnail = item.assets.get("thumbnail")
# Full-res image URL — what opens when user clicks
fullres_url = rendered.href if rendered else (thumbnail.href if thumbnail else None)
# Thumbnail URL — what shows in the popup card
thumb_url = thumbnail.href if thumbnail else (rendered.href if rendered else None)
result = {
"found": True,
"scene_id": item.id,
"datetime": item.datetime.isoformat() if item.datetime else None,
"cloud_cover": item.properties.get("eo:cloud_cover"),
"thumbnail_url": thumb_url,
"fullres_url": fullres_url,
"bbox": list(item.bbox) if item.bbox else None,
"platform": item.properties.get("platform", "Sentinel-2"),
}
_sentinel_cache[cache_key] = result
return result
except ImportError:
logger.warning("pystac-client not installed — Sentinel-2 search unavailable")
return {"found": False, "error": "pystac-client not installed"}
except Exception as e:
logger.error(f"Sentinel-2 search failed for ({lat}, {lng}): {e}")
return {"found": False, "error": str(e)}
-17
View File
@@ -1,17 +0,0 @@
import sys
import logging
logging.basicConfig(level=logging.DEBUG)
# Add backend directory to sys path so we can import modules
sys.path.append(r'f:\Codebase\Oracle\live-risk-dashboard\backend')
from services.data_fetcher import fetch_flights, latest_data
print("Testing fetch_flights...")
try:
fetch_flights()
print("Commercial flights count:", len(latest_data.get('commercial_flights', [])))
print("Private jets count:", len(latest_data.get('private_jets', [])))
except Exception as e:
import traceback
traceback.print_exc()
-38
View File
@@ -1,38 +0,0 @@
import json
from playwright.sync_api import sync_playwright
def scrape_liveuamap():
print("Launching playwright...")
with sync_playwright() as p:
# User agents are important for headless browsing
browser = p.chromium.launch(headless=True)
page = browser.new_page(user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
def handle_response(response):
try:
if not response.url.endswith(('js', 'css', 'png', 'jpg', 'woff2', 'svg', 'ico')):
print(f"Intercepted API Call: {response.url}")
except Exception:
pass
page.on("response", handle_response)
print("Navigating to liveuamap...")
try:
page.goto("https://liveuamap.com/", timeout=30000, wait_until="domcontentloaded")
page.wait_for_timeout(5000)
print("Grabbing all script tags...")
scripts = page.evaluate("() => Array.from(document.querySelectorAll('script')).map(s => s.innerText)")
for i, s in enumerate(scripts):
if 'JSON.parse' in s or 'markers' in s or 'JSON' in s:
with open(f"script_{i}.txt", "w", encoding="utf-8") as f:
f.write(s)
except Exception as e:
print("Playwright timeout or error:", e)
print("Closing browser...")
browser.close()
if __name__ == "__main__":
scrape_liveuamap()
-59
View File
@@ -1,59 +0,0 @@
import requests
import json
import time
import cloudscraper
def scrape_openmhz_systems():
print("Testing OpenMHZ undocumented API with Cloudscraper...")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
}
scraper = cloudscraper.create_scraper(browser={'browser': 'chrome', 'platform': 'windows', 'desktop': True})
try:
# Step 1: Hit the public systems list that the front-end map uses
res = scraper.get("https://api.openmhz.com/systems", headers=headers, timeout=15)
json_data = res.json()
systems = json_data.get('systems', []) if isinstance(json_data, dict) else []
print(f"Successfully spoofed OpenMHZ frontend. Found {len(systems)} active police/fire systems.")
if not systems:
return
# Inspect the first system (usually a major city)
city = systems[0]
sys_name = city.get('shortName')
print(f"Targeting System: {city.get('name')} ({sys_name})")
if not sys_name:
return
time.sleep(2) # Ethical delay
# Step 2: Query the recent calls for this specific system
# The frontend queries: https://api.openmhz.com/<system_name>/calls
calls_url = f"https://api.openmhz.com/{sys_name}/calls"
print(f"Fetching recent bursts: {calls_url}")
call_res = scraper.get(calls_url, headers=headers, timeout=15)
if call_res.status_code == 200:
call_json = call_res.json()
calls = call_json.get('calls', []) if isinstance(call_json, dict) else []
if calls and len(calls) > 0:
print(f"Intercepted {len(calls)} audio bursts.")
latest = calls[0]
print("LATEST INTERCEPT:")
print(f"Talkgroup: {latest.get('talkgroupNum')}")
print(f"Audio URL: {latest.get('url')}")
else:
print("No recent calls found for this system.")
else:
print(f"Failed to fetch calls. HTTP {call_res.status_code}")
except Exception as e:
print(f"Scrape Exception: {e}")
if __name__ == "__main__":
scrape_openmhz_systems()
-19
View File
@@ -1,19 +0,0 @@
import requests
def test_openmhz():
print("Testing OpenMHZ...")
res = requests.get("https://api.openmhz.com/systems")
if res.status_code == 200:
data = res.json()
print(f"OpenMHZ returned {len(data)} systems.")
print(f"Example: {data[0]['name']} ({data[0]['shortName']})")
else:
print(f"OpenMHZ Failed: {res.status_code}")
def test_scanner_radio():
print("Testing Scanner Radio...")
# Gordon Edwards app often uses something like this
# We will just try broadcastify public page scrape as a secondary fallback
pass
test_openmhz()
-55
View File
@@ -1,55 +0,0 @@
import feedparser
import requests
import re
feeds = {
"NPR": "https://feeds.npr.org/1004/rss.xml",
"BBC": "http://feeds.bbci.co.uk/news/world/rss.xml"
}
keyword_coords = {
"venezuela": (7.119, -66.589), "brazil": (-14.235, -51.925), "argentina": (-38.416, -63.616),
"colombia": (4.570, -74.297), "mexico": (23.634, -102.552), "united states": (38.907, -77.036),
" usa ": (38.907, -77.036), " us ": (38.907, -77.036), "washington": (38.907, -77.036),
"canada": (56.130, -106.346), "ukraine": (49.487, 31.272), "kyiv": (50.450, 30.523),
"russia": (61.524, 105.318), "moscow": (55.755, 37.617), "israel": (31.046, 34.851),
"gaza": (31.416, 34.333), "iran": (32.427, 53.688), "lebanon": (33.854, 35.862),
"syria": (34.802, 38.996), "yemen": (15.552, 48.516), "china": (35.861, 104.195),
"beijing": (39.904, 116.407), "taiwan": (23.697, 120.960), "north korea": (40.339, 127.510),
"south korea": (35.907, 127.766), "pyongyang": (39.039, 125.762), "seoul": (37.566, 126.978),
"japan": (36.204, 138.252), "afghanistan": (33.939, 67.709), "pakistan": (30.375, 69.345),
"india": (20.593, 78.962), " uk ": (55.378, -3.435), "london": (51.507, -0.127),
"france": (46.227, 2.213), "paris": (48.856, 2.352), "germany": (51.165, 10.451),
"berlin": (52.520, 13.405), "sudan": (12.862, 30.217), "congo": (-4.038, 21.758),
"south africa": (-30.559, 22.937), "nigeria": (9.082, 8.675), "egypt": (26.820, 30.802),
"zimbabwe": (-19.015, 29.154), "australia": (-25.274, 133.775), "middle east": (31.500, 34.800),
"europe": (48.800, 2.300), "africa": (0.000, 25.000), "america": (38.900, -77.000),
"south america": (-14.200, -51.900), "asia": (34.000, 100.000),
"california": (36.778, -119.417), "texas": (31.968, -99.901), "florida": (27.994, -81.760),
"new york": (40.712, -74.006), "virginia": (37.431, -78.656),
"british columbia": (53.726, -127.647), "ontario": (51.253, -85.323), "quebec": (52.939, -73.549),
"delhi": (28.704, 77.102), "new delhi": (28.613, 77.209), "mumbai": (19.076, 72.877),
"shanghai": (31.230, 121.473), "hong kong": (22.319, 114.169), "istanbul": (41.008, 28.978),
"dubai": (25.204, 55.270), "singapore": (1.352, 103.819)
}
for name, url in feeds.items():
r = requests.get(url)
feed = feedparser.parse(r.text)
for entry in feed.entries[:10]:
title = entry.get('title', '')
summary = entry.get('summary', '')
text = (title + " " + summary).lower()
padded_text = f" {text} "
matched_kw = None
for kw, coords in keyword_coords.items():
if kw.startswith(" ") or kw.endswith(" "):
if kw in padded_text:
matched_kw = kw
break
else:
if re.search(r'\b' + re.escape(kw) + r'\b', text):
matched_kw = kw
break
print(f"[{name}] {title}\n Matched: {matched_kw}\n Text: {text}\n")
-67
View File
@@ -1,67 +0,0 @@
import requests
from bs4 import BeautifulSoup
import json
def scrape_broadcastify_top():
print("Scraping Broadcastify Top Feeds...")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
try:
# The top 50 feeds page provides a wealth of listening data
res = requests.get("https://www.broadcastify.com/listen/top", headers=headers, timeout=10)
if res.status_code != 200:
print(f"Failed HTTP {res.status_code}")
return []
soup = BeautifulSoup(res.text, 'html.parser')
# The table of feeds is in a standard class
table = soup.find('table', {'class': 'btable'})
if not table:
print("Could not find feeds table.")
return []
feeds = []
rows = table.find_all('tr')[1:] # Skip header
for row in rows:
cols = row.find_all('td')
if len(cols) >= 5:
# Top layout: [Listeners, Feed ID (hidden), Location, Feed Name, Category, Genre]
listeners_str = cols[0].text.strip().replace(',', '')
listeners = int(listeners_str) if listeners_str.isdigit() else 0
# The link is usually in the Feed Name column
link_tag = cols[2].find('a')
if not link_tag:
continue
href = link_tag.get('href', '')
feed_id = href.split('/')[-1] if '/listen/feed/' in href else None
if not feed_id:
continue
location = cols[1].text.strip()
name = cols[2].text.strip()
feeds.append({
"id": feed_id,
"listeners": listeners,
"location": location,
"name": name,
"stream_url": f"https://broadcastify.cdnstream1.com/{feed_id}"
})
print(f"Successfully scraped {len(feeds)} top feeds.")
return feeds
except Exception as e:
print(f"Scrape error: {e}")
return []
if __name__ == "__main__":
top_feeds = scrape_broadcastify_top()
print(json.dumps(top_feeds[:3], indent=2))
-59
View File
@@ -1,59 +0,0 @@
import requests
import time
import math
import random
def test_fetch_and_triangulate():
t0 = time.time()
url = "https://api.adsb.lol/v2/lat/39.8/lon/-98.5/dist/1000"
try:
r = requests.get(url, timeout=10)
data = r.json()
print(f"Downloaded in {time.time() - t0:.2f}s")
if "ac" in data:
sampled = data["ac"]
print("Flights:", len(sampled))
else:
print("No 'ac' in response:", data)
# Load airports (mock for test)
airports = [{"lat": random.uniform(-90, 90), "lng": random.uniform(-180, 180), "iata": f"A{i}"} for i in range(4000)]
t1 = time.time()
for f in sampled:
lat = f.get("lat")
lng = f.get("lon")
heading = f.get("track", 0)
if lat is None or lng is None: continue
# Project 15 degrees (~1000 miles) backwards and forwards
dist_deg = 15.0
h_rad = math.radians(heading)
dy = math.cos(h_rad) * dist_deg
dx = math.sin(h_rad) * dist_deg
cos_lat = max(0.2, math.cos(math.radians(lat)))
origin_lat = lat - dy
origin_lng = lng - (dx / cos_lat)
dest_lat = lat + dy
dest_lng = lng + (dx / cos_lat)
# Find closest origin airport
best_o, min_o = None, float('inf')
for a in airports:
d = (a['lat'] - origin_lat)**2 + (a['lng'] - origin_lng)**2
if d < min_o: min_o = d; best_o = a
# Find closest dest airport
best_d, min_d = None, float('inf')
for a in airports:
d = (a['lat'] - dest_lat)**2 + (a['lng'] - dest_lng)**2
if d < min_d: min_d = d; best_d = a
print(f"Triangulated 500 flights against {len(airports)} airports in {time.time() - t1:.2f}s")
except Exception as e:
print("Error:", e)
test_fetch_and_triangulate()
-13
View File
@@ -1,13 +0,0 @@
from services.data_fetcher import fetch_airports, fetch_flights, cached_airports, latest_data
fetch_airports()
# We patch logger to see what happens inside fetch_flights
import logging
logging.basicConfig(level=logging.DEBUG)
# let's run fetch_flights
fetch_flights()
flights = latest_data.get('flights', [])
print(f"Total flights: {len(flights)}")
-45
View File
@@ -1,45 +0,0 @@
import json
import subprocess
import os
import time
proxy_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ais_proxy.js")
API_KEY = "75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb"
print(f"Proxy script: {proxy_script}")
print(f"Exists: {os.path.exists(proxy_script)}")
process = subprocess.Popen(
['node', proxy_script, API_KEY],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, # Separate stderr!
text=True,
bufsize=1
)
print("Process started, reading stdout...")
count = 0
start = time.time()
for line in iter(process.stdout.readline, ''):
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
msg_type = data.get("MessageType", "?")
mmsi = data.get("MetaData", {}).get("MMSI", 0)
count += 1
if count <= 5:
print(f" MSG {count}: type={msg_type} mmsi={mmsi}")
if count == 20:
elapsed = time.time() - start
print(f"\nReceived {count} messages in {elapsed:.1f}s — proxy is working!")
process.terminate()
break
except json.JSONDecodeError as e:
print(f" BAD JSON: {line[:100]}... err={e}")
if count == 0:
# Check stderr
stderr_out = process.stderr.read()
print(f"Zero messages received. stderr: {stderr_out[:500]}")
-54
View File
@@ -1,54 +0,0 @@
import json
import subprocess
import os
import time
import sys
proxy_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ais_proxy.js")
API_KEY = "75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb"
print(f"Proxy script: {proxy_script}")
process = subprocess.Popen(
['node', proxy_script, API_KEY],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
import threading
def read_stderr():
for line in iter(process.stderr.readline, ''):
print(f"[STDERR] {line.strip()}", file=sys.stderr)
t = threading.Thread(target=read_stderr, daemon=True)
t.start()
print("Process started, reading stdout for 15 seconds...")
count = 0
start = time.time()
while time.time() - start < 15:
line = process.stdout.readline()
if not line:
if process.poll() is not None:
print(f"Process exited with code {process.returncode}")
break
continue
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
msg_type = data.get("MessageType", "?")
mmsi = data.get("MetaData", {}).get("MMSI", 0)
count += 1
if count <= 5:
print(f" MSG {count}: type={msg_type} mmsi={mmsi}")
except json.JSONDecodeError as e:
print(f" BAD LINE: {line[:80]}...")
elapsed = time.time() - start
print(f"\nTotal {count} messages in {elapsed:.1f}s")
process.terminate()
-13
View File
@@ -1,13 +0,0 @@
import requests
import traceback
try:
print("Testing adsb.lol...")
r = requests.get("https://api.adsb.lol/v2/lat/39.8/lon/-98.5/dist/100", timeout=15)
print(f"Status: {r.status_code}")
d = r.json()
print(f"Aircraft: {len(d.get('ac', []))}")
except Exception as e:
print(f"Error type: {type(e).__name__}")
print(f"Error: {e}")
traceback.print_exc()
-11
View File
@@ -1,11 +0,0 @@
import json
import urllib.request
import time
time.sleep(5)
try:
data = urllib.request.urlopen('http://localhost:8000/api/live-data').read()
d = json.loads(data)
print(f"News: {len(d.get('news', []))} | Earthquakes: {len(d.get('earthquakes', []))} | Satellites: {len(d.get('satellites', []))} | CCTV: {len(d.get('cctv', []))}")
except Exception as e:
print(f"Error fetching API: {e}")
-56
View File
@@ -1,56 +0,0 @@
import requests
import json
# Step 1: Fetch some real flights from adsb.lol
print("Fetching real flights from adsb.lol...")
r = requests.get("https://api.adsb.lol/v2/lat/39.8/lon/-98.5/dist/250", timeout=10)
data = r.json()
ac = data.get("ac", [])
print("Got", len(ac), "aircraft")
# Step 2: Build a batch of real callsigns
planes = []
for f in ac[:20]: # Just 20 real flights
cs = str(f.get("flight", "")).strip()
lat = f.get("lat")
lon = f.get("lon")
if cs and lat and lon:
planes.append({"callsign": cs, "lat": lat, "lng": lon})
print("Built batch of", len(planes), "planes")
print("Sample plane:", json.dumps(planes[0]) if planes else "NONE")
# Step 3: Test routeset with real data
if planes:
payload = {"planes": planes}
print("Payload size:", len(json.dumps(payload)), "bytes")
r2 = requests.post("https://api.adsb.lol/api/0/routeset", json=payload, timeout=15)
print("Routeset HTTP:", r2.status_code)
if r2.status_code == 200:
result = r2.json()
print("Response type:", type(result).__name__)
print("Routes found:", len(result) if isinstance(result, list) else "dict")
if isinstance(result, list) and len(result) > 0:
print("First route:", json.dumps(result[0], indent=2))
else:
print("Error body:", r2.text[:500])
# Step 4: Test with bigger batch
print("\n--- Testing with 100 real flights ---")
planes100 = []
for f in ac[:120]:
cs = str(f.get("flight", "")).strip()
lat = f.get("lat")
lon = f.get("lon")
if cs and lat and lon:
planes100.append({"callsign": cs, "lat": lat, "lng": lon})
planes100 = planes100[:100]
print("Built batch of", len(planes100), "planes")
r3 = requests.post("https://api.adsb.lol/api/0/routeset", json={"planes": planes100}, timeout=15)
print("Routeset HTTP:", r3.status_code)
if r3.status_code == 200:
result3 = r3.json()
print("Routes found:", len(result3) if isinstance(result3, list) else "dict")
else:
print("Error body:", r3.text[:500])
-10
View File
@@ -1,10 +0,0 @@
from services.cctv_pipeline import init_db, TFLJamCamIngestor, LTASingaporeIngestor
init_db()
print("Initialized DB")
tfl = TFLJamCamIngestor()
print(f"TFL Cameras: {len(tfl.fetch_data())}")
nyc = LTASingaporeIngestor()
print(f"SGP Cameras: {len(nyc.fetch_data())}")
-24
View File
@@ -1,24 +0,0 @@
import requests
try:
print('Testing Seattle SDOT...')
r_sea = requests.get('https://data.seattle.gov/resource/65fc-btcc.json?$limit=5', headers={'X-App-Token': 'f2jdDBw5JMXPFOQyk64SKlPkn'})
print(r_sea.status_code)
try:
print(r_sea.json()[0])
except:
pass
except:
pass
try:
print('Testing NYC 511...')
r_nyc = requests.get('https://webcams.nyctmc.org/api/cameras', timeout=5)
print(r_nyc.status_code)
try:
print(len(r_nyc.json()))
print(r_nyc.json()[0])
except:
pass
except:
pass
-10
View File
@@ -1,10 +0,0 @@
import json, urllib.request
data = json.loads(urllib.request.urlopen('http://localhost:8000/api/live-data').read())
print(f"Commercial flights: {len(data.get('commercial_flights', []))}")
print(f"Private flights: {len(data.get('private_flights', []))}")
print(f"Private jets: {len(data.get('private_jets', []))}")
print(f"Military flights: {len(data.get('military_flights', []))}")
print(f"Tracked flights: {len(data.get('tracked_flights', []))}")
print(f"Ships: {len(data.get('ships', []))}")
print(f"CCTV: {len(data.get('cctv', []))}")
-38
View File
@@ -1,38 +0,0 @@
import json
import urllib.request
try:
data = json.loads(urllib.request.urlopen('http://localhost:8000/api/live-data').read())
# Tracked flights
tracked = data.get('tracked_flights', [])
print(f"=== TRACKED FLIGHTS: {len(tracked)} ===")
if tracked:
colors = {}
for t in tracked:
c = t.get('alert_color', 'NONE')
colors[c] = colors.get(c, 0) + 1
print(f" Colors: {colors}")
print(f" Sample: {json.dumps(tracked[0], indent=2)[:500]}")
# Ships
ships = data.get('ships', [])
print(f"\n=== SHIPS: {len(ships)} ===")
types = {}
for s in ships:
t = s.get('type', 'unknown')
types[t] = types.get(t, 0) + 1
print(f" Types: {types}")
if ships:
print(f" Sample: {json.dumps(ships[0], indent=2)[:300]}")
# News
news = data.get('news', [])
print(f"\n=== NEWS: {len(news)} ===")
# Earthquakes
quakes = data.get('earthquakes', [])
print(f"=== EARTHQUAKES: {len(quakes)} ===")
except Exception as e:
print(f"Error: {e}")
-23
View File
@@ -1,23 +0,0 @@
import json
import urllib.request
try:
data = json.loads(urllib.request.urlopen('http://localhost:8000/api/live-data').read())
tracked = data.get('tracked_flights', [])
colors = {}
for t in tracked:
c = t.get('alert_color', 'NONE')
colors[c] = colors.get(c, 0) + 1
print(f"TRACKED FLIGHTS: {len(tracked)} | Colors: {colors}")
ships = data.get('ships', [])
types = {}
for s in ships:
t = s.get('type', 'unknown')
types[t] = types.get(t, 0) + 1
print(f"SHIPS: {len(ships)} | Types: {types}")
print(f"NEWS: {len(data.get('news', []))} | EARTHQUAKES: {len(data.get('earthquakes', []))} | CCTV: {len(data.get('cctv', []))}")
except Exception as e:
print(f"Error: {e}")
-10
View File
@@ -1,10 +0,0 @@
import requests, json
url = "https://api.us.socrata.com/api/catalog/v1?domains=data.cityofnewyork.us&q=camera"
try:
r = requests.get(url)
res = r.json().get('results', [])
for d in res:
print(f"{d['resource']['id']} - {d['resource']['name']}")
except Exception as e:
print(e)
-36
View File
@@ -1,36 +0,0 @@
import json, urllib.request
data = json.loads(urllib.request.urlopen('http://localhost:8000/api/live-data').read())
# Check trail data
comm = data.get('commercial_flights', [])
mil = data.get('military_flights', [])
tracked = data.get('tracked_flights', [])
pvt = data.get('private_flights', [])
# Count flights with trails
comm_trails = [f for f in comm if f.get('trail') and len(f['trail']) > 0]
mil_trails = [f for f in mil if f.get('trail') and len(f['trail']) > 0]
tracked_trails = [f for f in tracked if f.get('trail') and len(f['trail']) > 0]
pvt_trails = [f for f in pvt if f.get('trail') and len(f['trail']) > 0]
print(f"Commercial: {len(comm)} total, {len(comm_trails)} with trails")
print(f"Military: {len(mil)} total, {len(mil_trails)} with trails")
print(f"Tracked: {len(tracked)} total, {len(tracked_trails)} with trails")
print(f"Private: {len(pvt)} total, {len(pvt_trails)} with trails")
# Show a sample trail
if mil_trails:
f = mil_trails[0]
print(f"\nSample trail ({f['callsign']}):")
print(f" Points: {len(f['trail'])}")
if f['trail']:
print(f" First: {f['trail'][0]}")
print(f" Last: {f['trail'][-1]}")
# Check for grounded planes
grounded = [f for f in comm if f.get('alt', 999) <= 500 and f.get('speed_knots', 999) < 30]
print(f"\nGrounded commercial: {len(grounded)}")
if grounded:
g = grounded[0]
print(f" Example: {g['callsign']} alt={g.get('alt')} speed={g.get('speed_knots')}")
-13
View File
@@ -1,13 +0,0 @@
import sqlite3
try:
conn = sqlite3.connect('cctv.db')
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT source_agency, COUNT(*) as count FROM cameras WHERE id LIKE 'OSM-%' GROUP BY source_agency")
rows = cur.fetchall()
print('OSM Cameras by City:')
for r in rows:
print(f"{r['source_agency']}: {r['count']}")
except Exception as e:
print('DB Error:', e)
-12
View File
@@ -1,12 +0,0 @@
import json
import urllib.request
import time
time.sleep(5)
try:
data = urllib.request.urlopen('http://localhost:8000/api/live-data').read()
d = json.loads(data)
ships = d.get('ships', [])
print(f"Ships: {len(ships)}")
except Exception as e:
print(f"Error fetching API: {e}")
-13
View File
@@ -1,13 +0,0 @@
import requests, json
print("Searching Socrata NYC/Seattle Cameras...")
try:
url = "https://api.us.socrata.com/api/catalog/v1?q=traffic cameras&limit=100"
r = requests.get(url)
res = r.json().get('results', [])
for d in res:
domain = d['metadata']['domain'].lower()
if 'seattle' in domain or 'newyork' in domain or 'nyc' in domain:
print(f"{d['resource']['id']} - {d['resource']['name']} ({domain})")
except Exception as e:
print(e)
-61
View File
@@ -1,61 +0,0 @@
"""Test trace endpoints with explicit output."""
import json, subprocess
hex_code = "a34bac" # DOJ166
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
date_str = now.strftime("%Y/%m/%d")
hex_prefix = hex_code[-2:]
# Test 1: adsb.fi trace_full
url1 = f"https://globe.adsb.fi/data/traces/{date_str}/{hex_prefix}/trace_full_{hex_code}.json"
print(f"URL1: {url1}")
r = subprocess.run(["curl.exe", "-s", "--max-time", "10", url1], capture_output=True, text=True, timeout=15)
if r.stdout.strip().startswith("{"):
data = json.loads(r.stdout)
print(f"SUCCESS! Keys: {list(data.keys())}")
if 'trace' in data:
pts = data['trace']
print(f"Trace points: {len(pts)}")
if pts:
print(f"FIRST (takeoff): {pts[0]}")
print(f"LAST (now): {pts[-1]}")
else:
print(f"Not JSON (first 100 chars): {r.stdout[:100]}")
# That response was behind cloudflare, try adsb.lol instead
# Test 2: adsb.lol hex lookup
url2 = f"https://api.adsb.lol/v2/hex/{hex_code}"
print(f"\nURL2: {url2}")
r2 = subprocess.run(["curl.exe", "-s", "--max-time", "10", url2], capture_output=True, text=True, timeout=15)
if r2.stdout.strip().startswith("{"):
data = json.loads(r2.stdout)
if 'ac' in data and data['ac']:
ac = data['ac'][0]
keys = sorted(ac.keys())
print(f"All keys ({len(keys)}): {keys}")
else:
print(f"Not JSON: {r2.stdout[:100]}")
# Test 3: Try adsb.lol trace
url3 = f"https://api.adsb.lol/trace/{hex_code}"
print(f"\nURL3: {url3}")
r3 = subprocess.run(["curl.exe", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", url3], capture_output=True, text=True, timeout=15)
print(f"HTTP status: {r3.stdout}")
# Test 4: Try globe.adsb.lol format
url4 = f"https://globe.adsb.lol/data/traces/{date_str}/{hex_prefix}/trace_full_{hex_code}.json"
print(f"\nURL4: {url4}")
r4 = subprocess.run(["curl.exe", "-s", "--max-time", "10", url4], capture_output=True, text=True, timeout=15)
if r4.stdout.strip().startswith("{"):
data = json.loads(r4.stdout)
print(f"SUCCESS! Keys: {list(data.keys())}")
if 'trace' in data:
pts = data['trace']
print(f"Trace points: {len(pts)}")
if pts:
print(f"FIRST (takeoff): {pts[0]}")
print(f"LAST (now): {pts[-1]}")
else:
print(f"Response: {r4.stdout[:150]}")
-8
View File
@@ -1,8 +0,0 @@
import asyncio, websockets
async def main():
try:
async with websockets.connect('wss://stream.aisstream.io/v0/stream') as ws:
print('Connected to AIS Stream!')
except Exception as e:
print(f"Error: {e}")
asyncio.run(main())
-37
View File
@@ -1,37 +0,0 @@
import os
import zipfile
zip_name = 'ShadowBroker_v0.1.zip'
if os.path.exists(zip_name):
try:
os.remove(zip_name)
except Exception as e:
print(f"Failed to delete old zip: {e}")
def add_dir(zipf, dir_path, excludes):
for root, dirs, files in os.walk(dir_path):
dirs[:] = [d for d in dirs if d not in excludes]
for f in files:
file_path = os.path.join(root, f)
zipf.write(file_path, arcname=file_path)
try:
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
print("Zipping backend...")
add_dir(zipf, 'backend', {'venv', '__pycache__'})
print("Zipping frontend...")
add_dir(zipf, 'frontend', {'node_modules', '.next'})
print("Zipping root files...")
zipf.write('docker-compose.yml')
zipf.write('start.bat')
zipf.write('start.sh')
zipf.write('README.md')
final_size = os.path.getsize(zip_name) / (1024 * 1024)
print(f"\n✅ SUCCESS! Created {zip_name}. Final size: {final_size:.2f} MB")
except Exception as e:
print(f"\n❌ ERROR creating zip: {e}")
Executable
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
ENGINE="${SHADOWBROKER_CONTAINER_ENGINE:-auto}"
COMPOSE_ARGS=()
COMPOSE_PROVIDER=""
find_docker_compose() {
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
COMPOSE_PROVIDER="docker compose"
return 0
fi
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
COMPOSE_PROVIDER="docker-compose"
return 0
fi
return 1
}
find_podman_compose() {
if command -v podman >/dev/null 2>&1 && podman compose version >/dev/null 2>&1; then
COMPOSE_CMD=(podman compose)
COMPOSE_PROVIDER="podman compose"
return 0
fi
if command -v podman-compose >/dev/null 2>&1; then
COMPOSE_CMD=(podman-compose)
COMPOSE_PROVIDER="podman-compose"
return 0
fi
return 1
}
if [ ! -f "$COMPOSE_FILE" ]; then
echo "[!] ERROR: Missing compose file: $COMPOSE_FILE"
exit 1
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--engine)
if [ "$#" -lt 2 ]; then
echo "[!] ERROR: --engine requires a value: docker, podman, or auto."
exit 1
fi
ENGINE="$2"
shift 2
;;
--engine=*)
ENGINE="${1#*=}"
shift
;;
*)
COMPOSE_ARGS+=("$1")
shift
;;
esac
done
if [ "${#COMPOSE_ARGS[@]}" -eq 0 ]; then
COMPOSE_ARGS=(up -d)
fi
if [ "${#COMPOSE_ARGS[@]}" -gt 0 ]; then
last_index=$((${#COMPOSE_ARGS[@]} - 1))
if [ "${COMPOSE_ARGS[$last_index]}" = "." ]; then
echo "[*] Ignoring trailing '.' argument."
unset "COMPOSE_ARGS[$last_index]"
fi
fi
if [ "${#COMPOSE_ARGS[@]}" -eq 0 ]; then
COMPOSE_ARGS=(up -d)
fi
COMPOSE_CMD=()
case "$ENGINE" in
auto)
find_docker_compose || find_podman_compose
;;
docker)
find_docker_compose
;;
podman)
find_podman_compose
;;
*)
echo "[!] ERROR: Unsupported engine '$ENGINE'. Use docker, podman, or auto."
exit 1
;;
esac
if [ "${#COMPOSE_CMD[@]}" -eq 0 ]; then
echo "[!] ERROR: No supported compose command found for engine '$ENGINE'."
echo " Install one of: docker compose, docker-compose, podman compose, or podman-compose."
exit 1
fi
if [ "$ENGINE" = "podman" ] && [ "$COMPOSE_PROVIDER" = "podman compose" ]; then
echo "[*] Using (podman): ${COMPOSE_CMD[*]}"
echo "[*] Note: 'podman compose' is Podman's wrapper command and may delegate to docker-compose based on your local Podman configuration."
else
echo "[*] Using ($ENGINE): ${COMPOSE_CMD[*]}"
fi
"${COMPOSE_CMD[@]}" -f "$COMPOSE_FILE" "${COMPOSE_ARGS[@]}"
+7 -6
View File
@@ -8,10 +8,9 @@ services:
ports:
- "8000:8000"
environment:
- AISSTREAM_API_KEY=${AISSTREAM_API_KEY}
- N2YO_API_KEY=${N2YO_API_KEY}
- OPENSKY_USERNAME=${OPENSKY_USERNAME}
- OPENSKY_PASSWORD=${OPENSKY_PASSWORD}
- AIS_API_KEY=${AIS_API_KEY}
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
volumes:
- backend_data:/app/data
@@ -20,11 +19,13 @@ services:
frontend:
build:
context: ./frontend
args:
# Optional: set this to your backend's external URL if using custom ports
# e.g. http://192.168.1.50:9096 — leave empty to auto-detect from browser
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
container_name: shadowbroker-frontend
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000
depends_on:
- backend
restart: unless-stopped
+13
View File
@@ -0,0 +1,13 @@
.next
.git
.env
.env.local
.env.*
eslint.config.mjs
node_modules
npm-debug.log*
build_logs*.txt
build_output.txt
build_error.txt
errors.txt
server_logs*.txt
+34 -11
View File
@@ -1,19 +1,42 @@
FROM node:18-alpine
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
RUN npm ci
# Copy source code
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
# NEXT_PUBLIC_* vars must exist at build time for Next.js to inline them.
# Default empty = auto-detect from browser hostname at runtime.
ARG NEXT_PUBLIC_API_URL=""
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# Expose port
EXPOSE 3000
# Next.js telemetry disable
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Start development server
CMD ["npm", "run", "dev:frontend"]
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
+36 -21
View File
@@ -1,36 +1,51 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# ShadowBroker Frontend
## Getting Started
Next.js 16 dashboard with MapLibre GL, Cesium, and Framer Motion.
First, run the development server:
## Development
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
npm install
npm run dev # http://localhost:3000
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## API URL Configuration
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
The frontend needs to reach the backend (default port `8000`). Resolution order:
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
1. **`NEXT_PUBLIC_API_URL`** env var — if set, used as-is (build-time, baked by Next.js)
2. **Server-side (SSR)** — falls back to `http://localhost:8000`
3. **Client-side (browser)** — auto-detects using `window.location.hostname:8000`
## Learn More
### Common scenarios
To learn more about Next.js, take a look at the following resources:
| Scenario | Action needed |
|----------|---------------|
| Local dev (`localhost:3000` + `localhost:8000`) | None — auto-detected |
| LAN access (`192.168.x.x:3000`) | None — auto-detected from browser hostname |
| Public deploy (same host, port 8000) | None — auto-detected |
| Backend on different port (e.g. `9096`) | Set `NEXT_PUBLIC_API_URL=http://host:9096` before build |
| Backend on different host | Set `NEXT_PUBLIC_API_URL=http://backend-host:8000` before build |
| Behind reverse proxy (e.g. `/api` path) | Set `NEXT_PUBLIC_API_URL=https://yourdomain.com` before build |
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
### Setting the variable
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
```bash
# Shell (Linux/macOS)
NEXT_PUBLIC_API_URL=http://myserver:8000 npm run build
## Deploy on Vercel
# PowerShell (Windows)
$env:NEXT_PUBLIC_API_URL="http://myserver:8000"; npm run build
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
# Docker Compose (set in .env file next to docker-compose.yml)
NEXT_PUBLIC_API_URL=http://myserver:8000
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
> **Note:** This is a build-time variable. Changing it requires rebuilding the frontend.
## Theming
Dark mode is the default. A light/dark toggle is available in the left panel toolbar.
Theme preference is persisted in `localStorage` as `sb-theme` and applied via
`data-theme` attribute on `<html>`. CSS variables in `globals.css` define all
structural colors for both themes.
+17
View File
@@ -0,0 +1,17 @@
> frontend@0.1.0 build
> next build
Γû▓ Next.js 16.1.6 (Turbopack)
- Environments: .env.local
Creating an optimized production build ...
Γ£ô Compiled successfully in 9.9s
Running TypeScript ...
Failed to compile.
Type error: Cannot find type definition file for 'mapbox__point-geometry'.
The file is in the program because:
Entry point for implicit type library 'mapbox__point-geometry'
Next.js build worker exited with code: 1 and signal: null
+92
View File
@@ -0,0 +1,92 @@
#0 building with "desktop-linux" instance using docker driver
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 838B 0.0s done
#1 DONE 0.0s
#2 [auth] library/node:pull token for registry-1.docker.io
#2 DONE 0.0s
#3 [internal] load metadata for docker.io/library/node:18-alpine
#3 DONE 0.6s
#4 [internal] load .dockerignore
#4 transferring context: 207B 0.0s done
#4 DONE 0.0s
#5 [base 1/1] FROM docker.io/library/node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e
#5 resolve docker.io/library/node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e 0.0s done
#5 DONE 0.0s
#6 [runner 2/8] RUN addgroup --system --gid 1001 nodejs
#6 CACHED
#7 [builder 1/4] WORKDIR /app
#7 CACHED
#8 [runner 3/8] RUN adduser --system --uid 1001 nextjs
#8 CACHED
#9 [internal] load build context
#9 transferring context: 3.49kB done
#9 DONE 0.0s
#10 [deps 1/4] RUN apk add --no-cache libc6-compat
#10 CACHED
#11 [deps 2/4] WORKDIR /app
#11 CACHED
#12 [deps 3/4] COPY package*.json ./
#12 CACHED
#13 [deps 4/4] RUN npm ci
#13 CACHED
#14 [builder 2/4] COPY --from=deps /app/node_modules ./node_modules
#14 CACHED
#15 [builder 3/4] COPY . .
#15 DONE 0.0s
#16 [builder 4/4] RUN npm run build
#16 1.391
#16 1.391 > frontend@0.2.0 build
#16 1.391 > next build
#16 1.391
#16 1.821 You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required.
#16 1.837 npm notice
#16 1.837 npm notice New major version of npm available! 10.8.2 -> 11.11.0
#16 1.837 npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.11.0
#16 1.837 npm notice To update run: npm install -g npm@11.11.0
#16 1.837 npm notice
#16 ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
------
> [builder 4/4] RUN npm run build:
1.391
1.391 > frontend@0.2.0 build
1.391 > next build
1.391
1.821 You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required.
1.837 npm notice
1.837 npm notice New major version of npm available! 10.8.2 -> 11.11.0
1.837 npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.11.0
1.837 npm notice To update run: npm install -g npm@11.11.0
1.837 npm notice
------
5 warnings found (use docker --debug to expand):
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 13)
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 18)
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 19)
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 35)
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 36)
Dockerfile:14
--------------------
12 | COPY . .
13 | ENV NEXT_TELEMETRY_DISABLED 1
14 | >>> RUN npm run build
15 |
16 | FROM base AS runner
--------------------
ERROR: failed to build: failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
+7
View File
@@ -2,6 +2,13 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'],
output: "standalone",
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
};
export default nextConfig;
+74 -165
View File
@@ -1,24 +1,21 @@
{
"name": "frontend",
"version": "0.1.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.1.0",
"version": "0.3.0",
"dependencies": {
"@types/leaflet": "^1.9.21",
"@types/mapbox-gl": "^3.4.1",
"@mapbox/point-geometry": "^1.1.0",
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"hls.js": "^1.6.15",
"lucide-react": "^0.575.0",
"mapbox-gl": "^3.19.0",
"maplibre-gl": "^4.7.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-leaflet": "^5.0.0",
"react-map-gl": "^8.1.0",
"satellite.js": "^6.0.2"
},
@@ -1054,12 +1051,6 @@
"node": ">= 0.6"
}
},
"node_modules/@mapbox/mapbox-gl-supported": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
"integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==",
"license": "BSD-3-Clause"
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
@@ -1078,17 +1069,6 @@
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.1"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
@@ -1303,17 +1283,6 @@
"node": ">=12.4.0"
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1553,6 +1522,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
@@ -1648,15 +1681,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/mapbox__point-geometry": {
"version": "1.0.87",
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-1.0.87.tgz",
@@ -1678,15 +1702,6 @@
"@types/pbf": "*"
}
},
"node_modules/@types/mapbox-gl": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.4.1.tgz",
"integrity": "sha512-NsGKKtgW93B+UaLPti6B7NwlxYlES5DpV5Gzj9F75rK5ALKsqSk15CiEHbOnTr09RGbr6ZYiCdI+59NNNcAImg==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
@@ -2863,12 +2878,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cheap-ruler": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
"integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==",
"license": "ISC"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2980,12 +2989,6 @@
"node": ">= 8"
}
},
"node_modules/csscolorparser": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4272,12 +4275,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/grid-index": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
"integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==",
"license": "ISC"
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -4389,6 +4386,12 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hls.js": {
"version": "1.6.15",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5101,12 +5104,6 @@
"node": ">=0.10"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5447,45 +5444,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mapbox-gl": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.19.0.tgz",
"integrity": "sha512-SFObIgdxN0b6hZNsRxSUmQWdVW9q9GM2gw4McgFbycyhekew7BZIh8V57pEERDWlI9x/5SxxraTit5Cf0hm9OA==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
"test/build/vite",
"test/build/webpack",
"test/build/typings"
],
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/mapbox-gl-supported": "^3.0.0",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.0.6",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@types/geojson": "^7946.0.16",
"@types/geojson-vt": "^3.2.5",
"@types/mapbox__point-geometry": "^1.0.87",
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"cheap-ruler": "^4.0.0",
"csscolorparser": "~1.0.3",
"earcut": "^3.0.1",
"geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.4",
"grid-index": "^1.1.0",
"kdbush": "^4.0.2",
"martinez-polygon-clipping": "^0.8.1",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.0.0",
"quickselect": "^3.0.0",
"supercluster": "^8.0.1",
"tinyqueue": "^3.0.0"
}
},
"node_modules/maplibre-gl": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
@@ -5587,17 +5545,6 @@
"pbf": "bin/pbf"
}
},
"node_modules/martinez-polygon-clipping": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz",
"integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==",
"license": "MIT",
"dependencies": {
"robust-predicates": "^2.0.4",
"splaytree": "^0.1.4",
"tinyqueue": "3.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6061,18 +6008,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6230,20 +6165,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-map-gl": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.0.tgz",
@@ -6383,12 +6304,6 @@
"node": ">=0.10.0"
}
},
"node_modules/robust-predicates": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
"license": "Unlicense"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -6784,12 +6699,6 @@
"node": ">=0.10.0"
}
},
"node_modules/splaytree": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz",
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
"license": "MIT"
},
"node_modules/split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
+6 -9
View File
@@ -1,27 +1,24 @@
{
"name": "frontend",
"version": "0.1.0",
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:frontend\" \"cd ../backend && python -m uvicorn main:app --reload\"",
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
"dev:frontend": "next dev",
"dev:backend": "cd ../backend && venv\\Scripts\\python.exe main.py",
"dev:backend": "node ../start-backend.js",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@types/leaflet": "^1.9.21",
"@types/mapbox-gl": "^3.4.1",
"@mapbox/point-geometry": "^1.1.0",
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"hls.js": "^1.6.15",
"lucide-react": "^0.575.0",
"mapbox-gl": "^3.19.0",
"maplibre-gl": "^4.7.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-leaflet": "^5.0.0",
"react-map-gl": "^8.1.0",
"satellite.js": "^6.0.2"
},
@@ -37,4 +34,4 @@
"tailwindcss": "^4",
"typescript": "^5"
}
}
}
+37 -12
View File
@@ -1,8 +1,40 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #000000;
--foreground: #ededed;
--bg-primary: #000000;
--bg-secondary: rgb(17, 24, 39);
--bg-tertiary: rgb(31, 41, 55);
--bg-panel: rgba(17, 24, 39, 0.8);
--border-primary: rgb(55, 65, 81);
--border-secondary: rgb(75, 85, 99);
--text-primary: rgb(243, 244, 246);
--text-secondary: rgb(156, 163, 175);
--text-muted: rgb(107, 114, 128);
--text-heading: rgb(236, 254, 255);
--hover-accent: rgba(8, 51, 68, 0.2);
--scrollbar-thumb: rgba(100, 116, 139, 0.3);
--scrollbar-thumb-hover: rgba(100, 116, 139, 0.5);
}
/* Light theme: only the map basemap changes — UI stays dark */
[data-theme="light"] {
--background: #000000;
--foreground: #ededed;
--bg-primary: #000000;
--bg-secondary: rgb(17, 24, 39);
--bg-tertiary: rgb(31, 41, 55);
--bg-panel: rgba(17, 24, 39, 0.8);
--border-primary: rgb(55, 65, 81);
--border-secondary: rgb(75, 85, 99);
--text-primary: rgb(243, 244, 246);
--text-secondary: rgb(156, 163, 175);
--text-muted: rgb(107, 114, 128);
--text-heading: rgb(236, 254, 255);
--hover-accent: rgba(8, 51, 68, 0.2);
--scrollbar-thumb: rgba(100, 116, 139, 0.3);
--scrollbar-thumb-hover: rgba(100, 116, 139, 0.5);
}
@theme inline {
@@ -12,13 +44,6 @@
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
@@ -35,12 +60,12 @@ body {
}
.styled-scrollbar::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.3);
background: var(--scrollbar-thumb);
border-radius: 10px;
}
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.5);
background: var(--scrollbar-thumb-hover);
}
.styled-scrollbar {
@@ -70,4 +95,4 @@ body {
/* Keep popups fully bright and interactive above the dimmed canvas */
.map-focus-active .maplibregl-popup {
z-index: 10 !important;
}
}
+3 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/lib/ThemeContext";
import "./globals.css";
const geistSans = Geist({
@@ -29,10 +30,10 @@ export default function RootLayout({
<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`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[var(--bg-primary)]`}
suppressHydrationWarning
>
{children}
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
+181 -27
View File
@@ -1,5 +1,6 @@
"use client";
import { API_BASE } from "@/lib/api";
import { useEffect, useState, useRef, useCallback } from "react";
import dynamic from 'next/dynamic';
import { motion } from "framer-motion";
@@ -14,10 +15,106 @@ import SettingsPanel from "@/components/SettingsPanel";
import MapLegend from "@/components/MapLegend";
import ScaleBar from "@/components/ScaleBar";
import ErrorBoundary from "@/components/ErrorBoundary";
import OnboardingModal, { useOnboarding } from "@/components/OnboardingModal";
import ChangelogModal, { useChangelog } from "@/components/ChangelogModal";
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
/* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */
function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void }) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState('');
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
// Parse raw coordinate input: "31.8, 34.8" or "31.8 34.8" or "-12.3, 45.6"
const parseCoords = (s: string): { lat: number; lng: number } | null => {
const m = s.trim().match(/^([+-]?\d+\.?\d*)[,\s]+([+-]?\d+\.?\d*)$/);
if (!m) return null;
const lat = parseFloat(m[1]), lng = parseFloat(m[2]);
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) return { lat, lng };
return null;
};
const handleSearch = async (q: string) => {
setValue(q);
// Check for raw coordinates first
const coords = parseCoords(q);
if (coords) {
setResults([{ label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, ...coords }]);
return;
}
// Geocode with Nominatim (debounced)
clearTimeout(timerRef.current);
if (q.trim().length < 2) { setResults([]); return; }
timerRef.current = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`, {
headers: { 'Accept-Language': 'en' },
});
const data = await res.json();
setResults(data.map((r: any) => ({ label: r.display_name, lat: parseFloat(r.lat), lng: parseFloat(r.lon) })));
} catch { setResults([]); }
setLoading(false);
}, 350);
};
const handleSelect = (r: { lat: number; lng: number }) => {
onLocate(r.lat, r.lng);
setOpen(false);
setValue('');
setResults([]);
};
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 bg-[var(--bg-primary)]/60 backdrop-blur-md border border-[var(--border-primary)] rounded-lg px-3 py-1.5 text-[9px] font-mono tracking-[0.15em] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
LOCATE
</button>
);
}
return (
<div className="relative w-[420px]">
<div className="flex items-center gap-2 bg-[var(--bg-primary)]/80 backdrop-blur-md border border-cyan-800/60 rounded-lg px-3 py-2 shadow-[0_0_20px_rgba(0,255,255,0.1)]">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-cyan-500 flex-shrink-0"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
ref={inputRef}
value={value}
onChange={(e) => handleSearch(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') { setOpen(false); setValue(''); setResults([]); } if (e.key === 'Enter' && results.length > 0) handleSelect(results[0]); }}
placeholder="Enter coordinates (31.8, 34.8) or place name..."
className="flex-1 bg-transparent text-[10px] text-[var(--text-primary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
/>
{loading && <div className="w-3 h-3 border border-cyan-500 border-t-transparent rounded-full animate-spin" />}
<button onClick={() => { setOpen(false); setValue(''); setResults([]); }} className="text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
{results.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-[var(--bg-secondary)]/95 backdrop-blur-md border border-[var(--border-primary)] rounded-lg overflow-hidden shadow-[0_-8px_30px_rgba(0,0,0,0.4)] max-h-[200px] overflow-y-auto styled-scrollbar">
{results.map((r, i) => (
<button key={i} onClick={() => handleSelect(r)} className="w-full text-left px-3 py-2 hover:bg-cyan-950/40 transition-colors border-b border-[var(--border-primary)]/50 last:border-0 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-cyan-500 flex-shrink-0"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{r.label}</span>
</button>
))}
</div>
)}
</div>
);
}
export default function Dashboard() {
const dataRef = useRef<any>({});
const [dataVersion, setDataVersion] = useState(0);
@@ -46,19 +143,33 @@ export default function Dashboard() {
global_incidents: true,
day_night: true,
gps_jamming: true,
gibs_imagery: false,
highres_satellite: false,
kiwisdr: false,
});
// NASA GIBS satellite imagery state
const [gibsDate, setGibsDate] = useState<string>(() => {
const d = new Date();
d.setDate(d.getDate() - 1);
return d.toISOString().slice(0, 10);
});
const [gibsOpacity, setGibsOpacity] = useState(0.6);
const [effects, setEffects] = useState({
bloom: true,
});
const [activeStyle, setActiveStyle] = useState('DEFAULT');
const stylesList = ['DEFAULT', 'FLIR', 'NVG', 'CRT'];
const stylesList = ['DEFAULT', 'SATELLITE', 'FLIR', 'NVG', 'CRT'];
const cycleStyle = () => {
setActiveStyle((prev) => {
const idx = stylesList.indexOf(prev);
return stylesList[(idx + 1) % stylesList.length];
const next = stylesList[(idx + 1) % stylesList.length];
// Auto-toggle High-Res Satellite layer with SATELLITE style
setActiveLayers((l: any) => ({ ...l, highres_satellite: next === 'SATELLITE' }));
return next;
});
};
@@ -74,6 +185,11 @@ export default function Dashboard() {
// Mouse coordinate + reverse geocoding state
const [mouseCoords, setMouseCoords] = useState<{ lat: number, lng: number } | null>(null);
const [locationLabel, setLocationLabel] = useState('');
// Onboarding & connection status
const { showOnboarding, setShowOnboarding } = useOnboarding();
const { showChangelog, setShowChangelog } = useChangelog();
const [backendStatus, setBackendStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const geocodeCache = useRef<Map<string, string>>(new Map());
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -146,11 +262,19 @@ export default function Dashboard() {
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);
const [dossierRes, sentinelRes] = await Promise.allSettled([
fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`),
fetch(`${API_BASE}/api/sentinel2/search?lat=${coords.lat}&lng=${coords.lng}`),
]);
let dossierData: any = {};
if (dossierRes.status === 'fulfilled' && dossierRes.value.ok) {
dossierData = await dossierRes.value.json();
}
let sentinelData = null;
if (sentinelRes.status === 'fulfilled' && sentinelRes.value.ok) {
sentinelData = await sentinelRes.value.json();
}
setRegionDossier({ ...dossierData, sentinel2: sentinelData });
} catch (e) {
console.error("Failed to fetch region dossier", e);
} finally {
@@ -175,9 +299,10 @@ export default function Dashboard() {
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
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
if (res.status === 304) { setBackendStatus('connected'); return; }
if (res.ok) {
setBackendStatus('connected');
fastEtag.current = res.headers.get('etag') || null;
const json = await res.json();
dataRef.current = { ...dataRef.current, ...json };
@@ -185,6 +310,7 @@ export default function Dashboard() {
}
} catch (e) {
console.error("Failed fetching fast live data", e);
setBackendStatus('disconnected');
}
};
@@ -192,7 +318,7 @@ export default function Dashboard() {
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 });
const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers });
if (res.status === 304) return;
if (res.ok) {
slowEtag.current = res.headers.get('etag') || null;
@@ -208,10 +334,10 @@ export default function Dashboard() {
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);
// Fast polling: 60s (matches backend update cadence — was 15s, wasting 75% on 304s)
// Slow polling: 120s (backend updates every 30min)
const fastInterval = setInterval(fetchFastData, 60000);
const slowInterval = setInterval(fetchSlowData, 120000);
return () => {
clearInterval(fastInterval);
@@ -220,7 +346,7 @@ export default function Dashboard() {
}, []);
return (
<main className="fixed inset-0 w-full h-full bg-black overflow-hidden font-sans">
<main className="fixed inset-0 w-full h-full bg-[var(--bg-primary)] overflow-hidden font-sans">
{/* MAPLIBRE WEBGL OVERLAY */}
<ErrorBoundary name="Map">
@@ -232,6 +358,8 @@ export default function Dashboard() {
onEntityClick={setSelectedEntity}
selectedEntity={selectedEntity}
flyToLocation={flyToLocation}
gibsDate={gibsDate}
gibsOpacity={gibsOpacity}
isEavesdropping={isEavesdropping}
onEavesdropClick={setEavesdropLocation}
onCameraMove={setCameraCenter}
@@ -266,10 +394,10 @@ export default function Dashboard() {
</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' }}>
<h1 className="text-2xl font-bold tracking-[0.4em] text-[var(--text-primary)] 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>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">GLOBAL THREAT INTERCEPT</span>
</div>
</motion.div>
@@ -279,7 +407,7 @@ export default function Dashboard() {
</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 className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-[var(--text-muted)] z-[200] pointer-events-none">
<div>RTX</div>
<div>VSR</div>
</div>
@@ -287,7 +415,7 @@ export default function Dashboard() {
{/* 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)} />
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} />
{/* LEFT BOTTOM - DISPLAY CONFIG */}
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
@@ -327,6 +455,7 @@ export default function Dashboard() {
setIsEavesdropping={setIsEavesdropping}
eavesdropLocation={eavesdropLocation}
cameraCenter={cameraCenter}
selectedEntity={selectedEntity}
/>
</div>
@@ -346,37 +475,40 @@ export default function Dashboard() {
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"
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-auto flex flex-col items-center gap-2"
>
{/* LOCATE BAR — search by coordinates or place name */}
<LocateBar onLocate={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
<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"
className="bg-[var(--bg-primary)]/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl px-6 py-2.5 flex items-center gap-6 shadow-[0_4px_30px_rgba(0,0,0,0.2)] 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-[8px] text-[var(--text-muted)] 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" />
<div className="w-px h-8 bg-[var(--border-primary)]" />
{/* 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]">
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">LOCATION</div>
<div className="text-[10px] text-[var(--text-secondary)] font-mono truncate max-w-[320px]">
{locationLabel || 'Hover over map...'}
</div>
</div>
{/* Divider */}
<div className="w-px h-8 bg-gray-700" />
<div className="w-px h-8 bg-[var(--border-primary)]" />
{/* 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-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">STYLE</div>
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
</div>
</div>
@@ -388,7 +520,7 @@ export default function Dashboard() {
{!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"
className="absolute bottom-6 right-6 z-[200] bg-[var(--bg-primary)]/60 backdrop-blur-md border border-[var(--border-primary)] 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>
@@ -425,6 +557,28 @@ export default function Dashboard() {
{/* MAP LEGEND */}
<MapLegend isOpen={legendOpen} onClose={() => setLegendOpen(false)} />
{/* ONBOARDING MODAL */}
{showOnboarding && (
<OnboardingModal
onClose={() => setShowOnboarding(false)}
onOpenSettings={() => { setShowOnboarding(false); setSettingsOpen(true); }}
/>
)}
{/* v0.4 CHANGELOG MODAL — shows once per version after onboarding */}
{!showOnboarding && showChangelog && (
<ChangelogModal onClose={() => setShowChangelog(false)} />
)}
{/* BACKEND DISCONNECTED BANNER */}
{backendStatus === 'disconnected' && (
<div className="absolute top-0 left-0 right-0 z-[9000] flex items-center justify-center py-2 bg-red-950/90 border-b border-red-500/40 backdrop-blur-sm">
<span className="text-[10px] font-mono tracking-widest text-red-400">
BACKEND OFFLINE Cannot reach {API_BASE}. Start the backend server or check your connection.
</span>
</div>
)}
</main>
);
}
+13 -13
View File
@@ -171,16 +171,16 @@ export default function AdvancedFilterModal({
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`}
className={`bg-[var(--bg-secondary)]/95 backdrop-blur-xl border ${c.border} rounded-xl shadow-[0_8px_60px_rgba(0,0,0,0.3)] 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"
className="flex items-center justify-between px-4 py-3 cursor-grab active:cursor-grabbing border-b border-[var(--border-primary)]/60 select-none flex-shrink-0"
onMouseDown={handleMouseDown}
>
<div className="flex items-center gap-2.5">
<GripHorizontal size={14} className="text-gray-600" />
<GripHorizontal size={14} className="text-[var(--text-muted)]" />
{icon}
<span className={`text-[11px] ${c.text} tracking-[0.25em] font-semibold`}>{title}</span>
{totalSelected > 0 && (
@@ -189,14 +189,14 @@ export default function AdvancedFilterModal({
</span>
)}
</div>
<button onClick={onClose} className="text-gray-600 hover:text-white transition-colors p-1 rounded hover:bg-gray-800">
<button onClick={onClose} className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors p-1 rounded hover:bg-[var(--bg-tertiary)]">
<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">
<div className="flex border-b border-[var(--border-primary)]/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;
@@ -257,7 +257,7 @@ export default function AdvancedFilterModal({
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`}
className={`w-full bg-[var(--bg-primary)]/50 border border-[var(--border-primary)]/70 rounded-lg text-[11px] text-[var(--text-secondary)] pl-8 pr-8 py-2 font-mono tracking-wide focus:outline-none focus:${c.border} focus:ring-1 ${c.ring} placeholder-[var(--text-muted)] transition-all`}
autoFocus
/>
{searchTerms[activeTab] && (
@@ -270,10 +270,10 @@ export default function AdvancedFilterModal({
)}
</div>
<div className="flex justify-between mt-1.5">
<span className="text-[8px] text-gray-600 tracking-widest">
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
{filteredOptions.length} AVAILABLE
</span>
<span className="text-[8px] text-gray-600 tracking-widest">
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
{draft[activeTab]?.size || 0} SELECTED
</span>
</div>
@@ -282,7 +282,7 @@ export default function AdvancedFilterModal({
{/* ── 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">
<div className="text-center py-8 text-[var(--text-muted)] text-[10px] tracking-widest">
NO MATCHING RESULTS
</div>
) : (
@@ -295,13 +295,13 @@ export default function AdvancedFilterModal({
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`
: `text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]/50 hover:text-[var(--text-primary)]`
}`}
>
{/* 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'
: 'border-[var(--border-primary)] group-hover:border-[var(--border-secondary)]'
}`}>
{isChecked && <Check size={9} strokeWidth={3} />}
</div>
@@ -316,7 +316,7 @@ export default function AdvancedFilterModal({
</div>
{/* ── Footer ── */}
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800/60 flex-shrink-0">
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-primary)]/60 flex-shrink-0">
<button
onClick={clearAll}
className="text-[9px] text-red-400/70 hover:text-red-300 tracking-widest transition-colors"
@@ -326,7 +326,7 @@ export default function AdvancedFilterModal({
<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"
className="text-[9px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] tracking-widest border border-[var(--border-primary)] rounded-md px-4 py-1.5 hover:bg-[var(--bg-tertiary)]/50 transition-all"
>
CANCEL
</button>
+174
View File
@@ -0,0 +1,174 @@
"use client";
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Satellite, Radio, MapPin, Image, Layers, Bug } from "lucide-react";
const CURRENT_VERSION = "0.4";
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const NEW_FEATURES = [
{
icon: <Satellite size={14} className="text-cyan-400" />,
title: "NASA GIBS Satellite Imagery",
desc: "Daily MODIS Terra true-color imagery with 30-day time slider, play/pause animation, and opacity control.",
color: "cyan",
},
{
icon: <Layers size={14} className="text-green-400" />,
title: "High-Res Satellite (Esri)",
desc: "Sub-meter resolution imagery — zoom into buildings and terrain. Toggle in Data Layers or cycle to SATELLITE style.",
color: "green",
},
{
icon: <Radio size={14} className="text-amber-400" />,
title: "KiwiSDR Radio Receivers",
desc: "500+ public SDR receivers plotted worldwide. Click any node to open a live radio tuner directly in the SIGINT panel.",
color: "amber",
},
{
icon: <Image size={14} className="text-blue-400" />,
title: "Sentinel-2 Intel Card",
desc: "Right-click anywhere — a floating intel card shows the latest Sentinel-2 satellite photo with capture date and cloud cover. Click to open full resolution.",
color: "blue",
},
{
icon: <MapPin size={14} className="text-purple-400" />,
title: "LOCATE Bar",
desc: "New search bar above coordinates — enter coordinates (31.8, 34.8) or place names (Tehran, Strait of Hormuz) to fly directly there.",
color: "purple",
},
{
icon: <Layers size={14} className="text-cyan-400" />,
title: "SATELLITE Style Preset",
desc: "STYLE button now cycles: DEFAULT → SATELLITE → FLIR → NVG → CRT. SATELLITE auto-enables high-res imagery.",
color: "cyan",
},
];
const BUG_FIXES = [
"Satellite imagery renders below all data icons — flights, ships, markers always visible on top",
"Sentinel-2 click now opens the actual high-res PNG image directly in browser",
"Light/dark theme fixed — UI stays dark, only the map basemap switches",
];
export function useChangelog() {
const [show, setShow] = useState(false);
useEffect(() => {
const seen = localStorage.getItem(STORAGE_KEY);
if (!seen) setShow(true);
}, []);
return { showChangelog: show, setShowChangelog: setShow };
}
interface ChangelogModalProps {
onClose: () => void;
}
const ChangelogModal = React.memo(function ChangelogModal({ onClose }: ChangelogModalProps) {
const handleDismiss = () => {
localStorage.setItem(STORAGE_KEY, "true");
onClose();
};
return (
<AnimatePresence>
<motion.div
key="changelog-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
onClick={handleDismiss}
/>
<motion.div
key="changelog-modal"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
>
<div
className="w-[560px] max-h-[85vh] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 rounded-xl shadow-[0_0_80px_rgba(0,200,255,0.08)] pointer-events-auto flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-5 pb-3 border-b border-[var(--border-primary)]/80">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<div className="px-2 py-1 rounded bg-cyan-500/15 border border-cyan-500/30 text-[10px] font-mono font-bold text-cyan-400 tracking-widest">
v{CURRENT_VERSION}
</div>
<h2 className="text-sm font-bold tracking-[0.15em] text-[var(--text-primary)] font-mono">
WHAT&apos;S NEW
</h2>
</div>
<p className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest mt-1">
SHADOWBROKER INTELLIGENCE PLATFORM UPDATE
</p>
</div>
<button
onClick={handleDismiss}
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-5 space-y-4">
{/* New Features */}
<div>
<div className="text-[9px] font-mono tracking-[0.2em] text-cyan-400 font-bold mb-3 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
NEW CAPABILITIES
</div>
<div className="space-y-2">
{NEW_FEATURES.map((f) => (
<div key={f.title} className="flex items-start gap-3 p-3 rounded-lg border border-[var(--border-primary)]/50 bg-[var(--bg-primary)]/30 hover:border-[var(--border-secondary)] transition-colors">
<div className="mt-0.5 flex-shrink-0">{f.icon}</div>
<div>
<div className="text-[10px] font-mono text-[var(--text-primary)] font-bold">{f.title}</div>
<div className="text-[9px] font-mono text-[var(--text-muted)] leading-relaxed mt-0.5">{f.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* Bug Fixes */}
<div>
<div className="text-[9px] font-mono tracking-[0.2em] text-green-400 font-bold mb-3 flex items-center gap-2">
<Bug size={10} className="text-green-400" />
FIXES &amp; IMPROVEMENTS
</div>
<div className="space-y-1.5">
{BUG_FIXES.map((fix, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-1.5">
<span className="text-green-500 text-[10px] mt-0.5 flex-shrink-0">+</span>
<span className="text-[9px] font-mono text-[var(--text-secondary)] leading-relaxed">{fix}</span>
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-[var(--border-primary)]/80 flex items-center justify-center">
<button
onClick={handleDismiss}
className="px-8 py-2.5 rounded-lg bg-cyan-500/15 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/25 text-[10px] font-mono tracking-[0.2em] transition-all"
>
ACKNOWLEDGED
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
});
export default ChangelogModal;
+1 -1
View File
@@ -34,7 +34,7 @@ class ErrorBoundary extends Component<Props, State> {
<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>
<div className="text-[var(--text-secondary)] 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"
+7 -7
View File
@@ -252,23 +252,23 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
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"
className="w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.2)] 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"
className="flex justify-between items-center p-3 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
onClick={() => setIsMinimized(!isMinimized)}
>
<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>
<span className="text-[10px] text-[var(--text-muted)] 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">
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <SlidersHorizontal size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -295,20 +295,20 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
return (
<div
key={section.key}
className={`border rounded-lg transition-all cursor-pointer group ${borderColors[section.color] || 'border-gray-800'} hover:bg-black/30`}
className={`border rounded-lg transition-all cursor-pointer group ${borderColors[section.color] || 'border-[var(--border-primary)]'} hover:bg-[var(--bg-primary)]/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>
<span className="text-[9px] text-[var(--text-secondary)] tracking-widest group-hover:text-[var(--text-primary)] 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" />
<SlidersHorizontal size={10} className="text-[var(--text-muted)] group-hover:text-[var(--text-secondary)] transition-colors" />
</div>
</div>
);
+13 -13
View File
@@ -171,14 +171,14 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
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" />
<div className="flex items-center gap-2 bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-lg px-3 py-2 focus-within:border-cyan-500/40 transition-colors">
<Search size={12} className="text-[var(--text-muted)] 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"
className="flex-1 bg-transparent text-[10px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
@@ -186,11 +186,11 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
onFocus={() => setIsOpen(true)}
/>
{query && (
<button onClick={() => { setQuery(""); setIsOpen(false); }} className="text-gray-600 hover:text-white transition-colors">
<button onClick={() => { setQuery(""); setIsOpen(false); }} className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
<X size={10} />
</button>
)}
<Crosshair size={12} className="text-gray-600 flex-shrink-0" />
<Crosshair size={12} className="text-[var(--text-muted)] flex-shrink-0" />
</div>
<AnimatePresence>
@@ -199,21 +199,21 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
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)]"
className="absolute top-full left-0 right-0 mt-1 bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-[var(--border-primary)] rounded-lg overflow-hidden z-50 shadow-[0_8px_30px_rgba(0,0,0,0.3)]"
>
<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"
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-[var(--hover-accent)] transition-colors text-left border-b border-[var(--border-primary)]/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">
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded bg-[var(--bg-secondary)] border border-[var(--border-primary)] 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 className="text-[10px] text-[var(--text-primary)] font-mono tracking-wide truncate">{r.label}</div>
<div className="text-[8px] text-[var(--text-muted)] font-mono truncate">{r.sublabel}</div>
</div>
<span className={`text-[7px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}>
{r.category}
@@ -221,7 +221,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
</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">
<div className="px-3 py-1.5 border-t border-[var(--border-primary)] bg-[var(--bg-primary)]/50 text-[8px] text-[var(--text-muted)] font-mono tracking-widest">
{filtered.length} RESULT{filtered.length !== 1 ? 'S' : ''} CLICK TO LOCATE
</div>
</motion.div>
@@ -231,9 +231,9 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
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"
className="absolute top-full left-0 right-0 mt-1 bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-[var(--border-primary)] rounded-lg z-50 p-4 text-center"
>
<div className="text-[9px] text-gray-600 font-mono tracking-widest">NO MATCHING ASSETS</div>
<div className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">NO MATCHING ASSETS</div>
</motion.div>
)}
</AnimatePresence>
-304
View File
@@ -1,304 +0,0 @@
"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='&copy; <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>
);
}
+13 -13
View File
@@ -217,10 +217,10 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
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)]"
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] max-h-[80vh] bg-[var(--bg-secondary)]/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.3)]"
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-gray-800/80 flex-shrink-0">
<div className="flex items-center justify-between p-5 border-b border-[var(--border-primary)]/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">
@@ -230,13 +230,13 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
</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>
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">MAP LEGEND</h2>
<span className="text-[9px] text-[var(--text-muted)] 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"
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
@@ -247,16 +247,16 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
{LEGEND.map((cat) => {
const isCollapsed = collapsed.has(cat.name);
return (
<div key={cat.name} className="rounded-lg border border-gray-800/60 overflow-hidden">
<div key={cat.name} className="rounded-lg border border-[var(--border-primary)]/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"
className="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/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" />}
{isCollapsed ? <ChevronDown size={12} className="text-[var(--text-muted)]" /> : <ChevronUp size={12} className="text-[var(--text-muted)]" />}
</button>
{/* Items */}
@@ -267,13 +267,13 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="border-t border-gray-800/40"
className="border-t border-[var(--border-primary)]/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">
<div key={idx} className="flex items-center gap-3 px-4 py-1.5 hover:bg-[var(--bg-secondary)]/30 transition-colors">
<IconImg svg={item.svg} />
<span className="text-[11px] text-gray-300 font-mono">{item.label}</span>
<span className="text-[11px] text-[var(--text-secondary)] font-mono">{item.label}</span>
</div>
))}
</div>
@@ -286,8 +286,8 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
</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">
<div className="p-3 border-t border-[var(--border-primary)]/80 flex-shrink-0">
<div className="text-[9px] text-[var(--text-muted)] font-mono text-center tracking-wider">
{LEGEND.reduce((sum, c) => sum + c.items.length, 0)} ICON DEFINITIONS ACROSS {LEGEND.length} CATEGORIES
</div>
</div>
+321 -79
View File
@@ -1,5 +1,6 @@
"use client";
import { API_BASE } from "@/lib/api";
import React, { useMemo, useState, useEffect, useCallback, useRef } from "react";
import Map, { Source, Layer, MapRef, ViewState, Popup, Marker } from "react-map-gl/maplibre";
import "maplibre-gl/dist/maplibre-gl.css";
@@ -8,6 +9,7 @@ import ScaleBar from "@/components/ScaleBar";
import maplibregl from "maplibre-gl";
import { AlertTriangle } from "lucide-react";
import WikiImage from "@/components/WikiImage";
import { useTheme } from "@/lib/ThemeContext";
const svgPlaneCyan = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="cyan" 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 svgPlaneYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="yellow" 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>`)}`;
@@ -111,10 +113,10 @@ function classifyAircraft(model: string, category?: string): 'heli' | 'turboprop
// --- Smooth position interpolation helpers ---
// Given heading (degrees) and speed (knots), compute new lat/lng after dt seconds
function interpolatePosition(lat: number, lng: number, headingDeg: number, speedKnots: number, dtSeconds: number, maxDist = 3704): [number, number] {
function interpolatePosition(lat: number, lng: number, headingDeg: number, speedKnots: number, dtSeconds: number, maxDist = 0, maxDt = 65): [number, number] {
if (!speedKnots || speedKnots <= 0 || dtSeconds <= 0) return [lat, lng];
// Cap interpolation to max 6 seconds to prevent runaway drift when data is stale
const clampedDt = Math.min(dtSeconds, 6);
// Cap interpolation time to prevent runaway drift when data is stale
const clampedDt = Math.min(dtSeconds, maxDt);
// 1 knot = 1 nautical mile/hour = 1852 m/h
const speedMps = speedKnots * 0.5144; // meters per second
const dist = maxDist > 0 ? Math.min(speedMps * clampedDt, maxDist) : speedMps * clampedDt;
@@ -149,13 +151,29 @@ const darkStyle = {
}
},
layers: [
{
id: 'carto-dark-layer',
{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }
]
};
const lightStyle = {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
'carto-light': {
type: 'raster',
source: 'carto-dark',
minzoom: 0,
maxzoom: 22
tiles: [
"https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png"
],
tileSize: 256
}
},
layers: [
{ id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }
]
};
@@ -184,8 +202,10 @@ const MISSION_ICON_MAP: Record<string, string> = {
'commercial_imaging': 'sat-com', 'space_station': 'sat-station'
};
const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints }: any) => {
const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints, gibsDate, gibsOpacity }: any) => {
const mapRef = useRef<MapRef>(null);
const { theme } = useTheme();
const mapThemeStyle = useMemo(() => theme === 'light' ? lightStyle : darkStyle, [theme]);
const [viewState, setViewState] = useState<ViewState>({
longitude: 0,
@@ -231,14 +251,15 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
const [interpTick, setInterpTick] = useState(0);
const dataTimestamp = useRef<number>(Date.now());
// Track when flight/ship data actually changes (new fetch arrived)
// Track when flight/ship/satellite data actually changes (new fetch arrived)
useEffect(() => {
dataTimestamp.current = Date.now();
}, [data?.commercial_flights, data?.ships]);
}, [data?.commercial_flights, data?.ships, data?.satellites]);
// Tick every 5s between data refreshes to animate flight positions
// Tick every 2s between data refreshes to animate positions
// Satellites move ~7km/s so need frequent updates for smooth motion
useEffect(() => {
const timer = setInterval(() => setInterpTick(t => t + 1), 10000);
const timer = setInterval(() => setInterpTick(t => t + 1), 2000);
return () => clearInterval(timer);
}, []);
@@ -267,7 +288,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
if (callsign && callsign !== prevCallsign.current) {
prevCallsign.current = callsign;
fetch(`http://localhost:8000/api/route/${callsign}`)
fetch(`${API_BASE}/api/route/${callsign}`)
.then(res => res.json())
.then(routeData => {
if (isMounted) setDynamicRoute(routeData);
@@ -367,19 +388,64 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
}, [activeLayers.cctv, data?.cctv, inView]);
// KiwiSDR receivers — clustered amber dots
const kiwisdrGeoJSON = useMemo(() => {
if (!activeLayers.kiwisdr || !data?.kiwisdr?.length) return null;
return {
type: 'FeatureCollection' as const,
features: data.kiwisdr.filter((k: any) => k.lat != null && k.lon != null && inView(k.lat, k.lon)).map((k: any, i: number) => ({
type: 'Feature' as const,
properties: {
id: i,
type: 'kiwisdr',
name: k.name || 'Unknown SDR',
url: k.url || '',
users: k.users || 0,
users_max: k.users_max || 0,
bands: k.bands || '',
antenna: k.antenna || '',
location: k.location || '',
},
geometry: { type: 'Point' as const, coordinates: [k.lon, k.lat] }
}))
};
}, [activeLayers.kiwisdr, data?.kiwisdr, inView]);
// Load Images into the Map Style once loaded
const onMapLoad = useCallback((e: any) => {
const map = e.target;
// Track which images are still loading so we can retry on styleimagemissing
const pendingImages: Record<string, string> = {};
const loadImg = (id: string, url: string) => {
if (!map.hasImage(id)) {
pendingImages[id] = url;
const img = new Image();
img.crossOrigin = "anonymous";
img.src = url;
img.onload = () => map.addImage(id, img);
img.onload = () => {
if (!map.hasImage(id)) map.addImage(id, img);
delete pendingImages[id];
};
}
};
// Suppress "image not found" warnings — retry when the async load finishes
map.on('styleimagemissing', (ev: any) => {
const id = ev.id;
const url = pendingImages[id];
if (url) {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = url;
img.onload = () => {
if (!map.hasImage(id)) map.addImage(id, img);
delete pendingImages[id];
};
}
});
// Legacy generic plane icons (still used as fallbacks)
loadImg('svgPlaneCyan', svgPlaneCyan);
loadImg('svgPlaneYellow', svgPlaneYellow);
@@ -498,10 +564,12 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
return [newLng, newLat];
};
// Helper: interpolate a satellite's position — reuses interpolatePosition with no distance cap
// Helper: interpolate a satellite's position between API updates
// Satellites have deterministic orbits so linear interpolation over 60s is accurate
// maxDt=65 allows full interval coverage (60s update + 5s buffer)
const interpSat = (s: any): [number, number] => {
if (!s.speed_knots || s.speed_knots <= 0 || dtSeconds < 1) return [s.lng, s.lat];
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, s.heading || 0, s.speed_knots, dtSeconds, 0);
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, s.heading || 0, s.speed_knots, dtSeconds, 0, 65);
return [newLng, newLat];
};
@@ -669,10 +737,17 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}, [activeLayers.ships_important, activeLayers.ships_civilian, activeLayers.ships_passenger, data?.ships, inView]);
// Extract ship cluster positions from the map source for HTML labels
const shipClusterHandlerRef = useRef<(() => void) | null>(null);
useEffect(() => {
const map = mapRef.current?.getMap();
if (!map || !shipsGeoJSON) { setShipClusters([]); return; }
// Remove previous handler if it exists
if (shipClusterHandlerRef.current) {
map.off('moveend', shipClusterHandlerRef.current);
map.off('sourcedata', shipClusterHandlerRef.current);
}
const update = () => {
try {
const features = map.querySourceFeatures('ships');
@@ -689,6 +764,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
setShipClusters(unique);
} catch { setShipClusters([]); }
};
shipClusterHandlerRef.current = update;
map.on('moveend', update);
map.on('sourcedata', update);
@@ -698,10 +774,16 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}, [shipsGeoJSON]);
// Extract earthquake cluster positions from the map source for HTML labels
const eqClusterHandlerRef = useRef<(() => void) | null>(null);
useEffect(() => {
const map = mapRef.current?.getMap();
if (!map || !earthquakesGeoJSON) { setEqClusters([]); return; }
if (eqClusterHandlerRef.current) {
map.off('moveend', eqClusterHandlerRef.current);
map.off('sourcedata', eqClusterHandlerRef.current);
}
const update = () => {
try {
const features = map.querySourceFeatures('earthquakes');
@@ -718,6 +800,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
setEqClusters(unique);
} catch { setEqClusters([]); }
};
eqClusterHandlerRef.current = update;
map.on('moveend', update);
map.on('sourcedata', update);
@@ -783,7 +866,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
return { type: 'FeatureCollection', features };
}, [selectedEntity, data, dynamicRoute]);
// Trail history GeoJSON: shows where an aircraft has been (from backend trail data)
// Trail history GeoJSON: shows where the SELECTED aircraft has been (only for no-route flights)
const trailGeoJSON = useMemo(() => {
if (!selectedEntity || !data) return null;
@@ -795,10 +878,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
else if (selectedEntity.type === 'tracked_flight') entity = data?.tracked_flights?.[selectedEntity.id as number];
if (!entity || !entity.trail || entity.trail.length < 2) return null;
// Only show trail if this flight has no known route
if (entity.origin_name && entity.origin_name !== 'UNKNOWN') return null;
// trail points are [lat, lng, alt, timestamp] — convert to [lng, lat] for GeoJSON
const coords = entity.trail.map((p: number[]) => [p[1], p[0]]);
// Append current position as the final point
if (entity.lat != null && entity.lng != null) {
coords.push([entity.lng, entity.lat]);
}
@@ -848,42 +931,58 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
const GAP = 6; // Minimum gap between boxes
const MAX_OFFSET = 350;
// 2. Iterative Collision Resolution Loop
const maxIter = 40;
// 2. Grid-based Collision Resolution (O(n) per iteration instead of O(n²))
const CELL_W = BOX_W + GAP;
const CELL_H = 100; // Approximate max box height + gap
const maxIter = 30;
for (let iter = 0; iter < maxIter; iter++) {
let moved = false;
// Build spatial grid
const grid: Record<string, number[]> = {};
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const a = items[i];
const b = items[j];
const aX = a.x + a.offsetX;
const aY = a.y + a.offsetY;
const bX = b.x + b.offsetX;
const bY = b.y + b.offsetY;
const dx = Math.abs(aX - bX);
const dy = Math.abs(aY - bY);
// Per-pair min distances using each box's actual estimated height
const minDistX = BOX_W + GAP;
const minDistY = (a.boxH + b.boxH) / 2 + GAP;
if (dx < minDistX && dy < minDistY) {
moved = true;
const overlapX = minDistX - dx;
const overlapY = minDistY - dy;
// Push each by half the overlap + 1px to guarantee separation
if (overlapY < overlapX) {
const push = (overlapY / 2) + 1;
if (aY <= bY) { a.offsetY -= push; b.offsetY += push; }
else { a.offsetY += push; b.offsetY -= push; }
} else {
const push = (overlapX / 2) + 1;
if (aX <= bX) { a.offsetX -= push; b.offsetX += push; }
else { a.offsetX += push; b.offsetX -= push; }
const cx = Math.floor((items[i].x + items[i].offsetX) / CELL_W);
const cy = Math.floor((items[i].y + items[i].offsetY) / CELL_H);
const key = `${cx},${cy}`;
(grid[key] ??= []).push(i);
}
// Check collisions only within same/adjacent cells
const checked = new Set<string>();
for (const key in grid) {
const [cx, cy] = key.split(',').map(Number);
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const nk = `${cx + dx},${cy + dy}`;
if (!grid[nk]) continue;
const pairKey = cx + dx < cx || (cx + dx === cx && cy + dy < cy) ? `${nk}|${key}` : `${key}|${nk}`;
if (key !== nk && checked.has(pairKey)) continue;
checked.add(pairKey);
const cellA = grid[key];
const cellB = key === nk ? cellA : grid[nk];
for (const i of cellA) {
const startJ = key === nk ? cellA.indexOf(i) + 1 : 0;
for (let jIdx = startJ; jIdx < cellB.length; jIdx++) {
const j = cellB[jIdx];
if (i === j) continue;
const a = items[i], b = items[j];
const adx = Math.abs((a.x + a.offsetX) - (b.x + b.offsetX));
const ady = Math.abs((a.y + a.offsetY) - (b.y + b.offsetY));
const minDistX = BOX_W + GAP;
const minDistY = (a.boxH + b.boxH) / 2 + GAP;
if (adx < minDistX && ady < minDistY) {
moved = true;
const overlapX = minDistX - adx;
const overlapY = minDistY - ady;
if (overlapY < overlapX) {
const push = (overlapY / 2) + 1;
if ((a.y + a.offsetY) <= (b.y + b.offsetY)) { a.offsetY -= push; b.offsetY += push; }
else { a.offsetY += push; b.offsetY -= push; }
} else {
const push = (overlapX / 2) + 1;
if ((a.x + a.offsetX) <= (b.x + b.offsetX)) { a.offsetX -= push; b.offsetX += push; }
else { a.offsetX += push; b.offsetX -= push; }
}
}
}
}
}
}
@@ -941,7 +1040,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
return {
type: 'FeatureCollection',
features: data.uavs.map((uav: any, i: number) => {
if (uav.lat == null || uav.lng == null) return null;
if (uav.lat == null || uav.lng == null || !inView(uav.lat, uav.lng)) return null;
return {
type: 'Feature',
properties: {
@@ -962,7 +1061,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
}).filter(Boolean)
};
}, [activeLayers.military, data?.uavs]);
}, [activeLayers.military, data?.uavs, inView]);
// UAV operational range circle — only for the selected UAV
const uavRangeGeoJSON = useMemo(() => {
@@ -996,6 +1095,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
type: 'FeatureCollection',
features: data.gdelt.map((g: any, i: number) => {
if (!g.geometry || !g.geometry.coordinates) return null;
const [gLng, gLat] = g.geometry.coordinates;
if (!inView(gLat, gLng)) return null;
return {
type: 'Feature',
properties: { id: i, type: 'gdelt', title: g.title },
@@ -1003,14 +1104,14 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
}).filter(Boolean)
};
}, [activeLayers.global_incidents, data?.gdelt]);
}, [activeLayers.global_incidents, data?.gdelt, inView]);
const liveuaGeoJSON = useMemo(() => {
if (!activeLayers.global_incidents || !data?.liveuamap) return null;
return {
type: 'FeatureCollection',
features: data.liveuamap.map((incident: any, i: number) => {
if (incident.lat == null || incident.lng == null) return null;
if (incident.lat == null || incident.lng == null || !inView(incident.lat, incident.lng)) return null;
const isViolent = /bomb|missil|strike|attack|kill|destroy|fire|shoot|expl|raid/i.test(incident.title || "");
return {
type: 'Feature',
@@ -1019,7 +1120,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
}).filter(Boolean)
};
}, [activeLayers.global_incidents, data?.liveuamap]);
}, [activeLayers.global_incidents, data?.liveuamap, inView]);
const frontlineGeoJSON = useMemo(() => {
if (!activeLayers.ukraine_frontline || !data?.frontlines) return null;
@@ -1043,7 +1144,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
frontlineGeoJSON && 'ukraine-frontline-layer',
earthquakesGeoJSON && 'earthquakes-layer',
satellitesGeoJSON && 'satellites-layer',
cctvGeoJSON && 'cctv-layer'
cctvGeoJSON && 'cctv-layer',
kiwisdrGeoJSON && 'kiwisdr-layer'
].filter(Boolean) as string[];
@@ -1075,7 +1177,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
evt.preventDefault();
onRightClick?.({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
}}
mapStyle={darkStyle as any}
mapStyle={mapThemeStyle as any}
mapLib={maplibregl}
onLoad={onMapLoad}
onIdle={updateBounds}
@@ -1103,6 +1205,50 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}
}}
>
{/* Esri World Imagery — high-res static satellite (zoom 0-18+) */}
{activeLayers.highres_satellite && (
<Source
id="esri-world-imagery"
type="raster"
tiles={['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}']}
tileSize={256}
maxzoom={18}
attribution="Esri, Maxar, Earthstar Geographics"
>
<Layer
id="esri-world-imagery-layer"
type="raster"
beforeId="imagery-ceiling"
paint={{
'raster-opacity': 1,
'raster-fade-duration': 300
}}
/>
</Source>
)}
{/* NASA GIBS MODIS Terra — daily satellite imagery overlay */}
{activeLayers.gibs_imagery && gibsDate && (
<Source
key={`gibs-${gibsDate}`}
id="gibs-modis"
type="raster"
tiles={[`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/MODIS_Terra_CorrectedReflectance_TrueColor/default/${gibsDate}/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg`]}
tileSize={256}
maxzoom={9}
>
<Layer
id="gibs-modis-layer"
type="raster"
beforeId="imagery-ceiling"
paint={{
'raster-opacity': gibsOpacity ?? 0.6,
'raster-fade-duration': 0
}}
/>
</Source>
)}
{/* SOLAR TERMINATOR — night overlay */}
{activeLayers.day_night && nightGeoJSON && (
<Source id="night-overlay" type="geojson" data={nightGeoJSON as any}>
@@ -1723,6 +1869,43 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Source>
)}
{/* KiwiSDR Receivers — clustered amber dots */}
{kiwisdrGeoJSON && (
<Source id="kiwisdr" type="geojson" data={kiwisdrGeoJSON as any} cluster={true} clusterRadius={50} clusterMaxZoom={14}>
<Layer
id="kiwisdr-clusters"
type="circle"
filter={['has', 'point_count']}
paint={{
'circle-color': '#f59e0b',
'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 50, 24, 200, 30],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#d97706'
}}
/>
<Layer
id="kiwisdr-cluster-count"
type="symbol"
filter={['has', 'point_count']}
layout={{ 'text-field': '{point_count_abbreviated}', 'text-size': 12, 'text-allow-overlap': true }}
paint={{ 'text-color': '#ffffff', 'text-halo-color': '#000000', 'text-halo-width': 1 }}
/>
<Layer
id="kiwisdr-layer"
type="circle"
filter={['!', ['has', 'point_count']]}
paint={{
'circle-color': '#f59e0b',
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2, 8, 4, 14, 6],
'circle-opacity': 0.9,
'circle-stroke-width': 1,
'circle-stroke-color': '#d97706'
}}
/>
</Source>
)}
{/* Satellite positions — mission-type icons */}
{satellitesGeoJSON && (
<Source id="satellites" type="geojson" data={satellitesGeoJSON as any}>
@@ -1792,7 +1975,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
Altitude: <span style={{ color: '#44ff88' }}>{sat.alt_km?.toLocaleString()} km</span>
</div>
{sat.wiki && (
<div className="mt-2 border-t border-gray-700/50 pt-2">
<div className="mt-2 border-t border-[var(--border-primary)]/50 pt-2">
<WikiImage wikiUrl={sat.wiki} label={sat.sat_type || sat.name} maxH="max-h-28" accent="hover:border-cyan-500/50" />
</div>
)}
@@ -1844,7 +2027,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</div>
)}
{uav.wiki && (
<div className="mt-2 border-t border-gray-700/50 pt-2">
<div className="mt-2 border-t border-[var(--border-primary)]/50 pt-2">
<WikiImage wikiUrl={uav.wiki} label={uav.callsign} maxH="max-h-28" accent="hover:border-red-500/50" />
</div>
)}
@@ -1863,25 +2046,25 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
anchor="bottom"
offset={15}
>
<div className="bg-black/90 backdrop-blur-md border border-orange-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,140,0,0.4)] pointer-events-auto overflow-hidden w-[300px]">
<div className="bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-orange-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,140,0,0.4)] pointer-events-auto overflow-hidden w-[300px]">
<div className="p-2 border-b border-orange-500/30 bg-orange-950/40 flex justify-between items-center">
<h2 className="text-[10px] tracking-widest font-bold text-orange-400 flex items-center gap-1">
<AlertTriangle size={12} className="text-orange-400" /> NEWS ON THE GROUND
</h2>
<button onClick={() => onEntityClick?.(null)} className="text-gray-400 hover:text-white"></button>
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"></button>
</div>
<div className="p-3 flex flex-col gap-2">
<div className="flex justify-between items-center border-b border-gray-800 pb-1">
<span className="text-gray-500 text-[9px]">LOCATION</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
<span className="text-[var(--text-muted)] text-[9px]">LOCATION</span>
<span className="text-white text-[10px] font-bold text-right ml-2 break-words max-w-[150px]">{data.gdelt[selectedEntity.id as number].properties?.name || 'UNKNOWN REGION'}</span>
</div>
<div className="flex flex-col gap-1 mt-1">
<span className="text-gray-500 text-[9px]">LATEST REPORTS: ({data.gdelt[selectedEntity.id as number].properties?.count || 1})</span>
<span className="text-[var(--text-muted)] text-[9px]">LATEST REPORTS: ({data.gdelt[selectedEntity.id as number].properties?.count || 1})</span>
<div className="flex flex-col gap-2 max-h-[200px] overflow-y-auto styled-scrollbar mt-1">
{(() => {
const urls: string[] = data.gdelt[selectedEntity.id as number].properties?._urls_list || [];
const headlines: string[] = data.gdelt[selectedEntity.id as number].properties?._headlines_list || [];
if (urls.length === 0) return <span className="text-gray-500 text-[9px]">No articles available.</span>;
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[9px]">No articles available.</span>;
return urls.map((url: string, idx: number) => (
<a
key={idx}
@@ -1889,7 +2072,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-orange-400 text-[9px] underline hover:text-orange-300 block py-1 border-b border-gray-800/50 last:border-0 cursor-pointer"
className="text-orange-400 text-[9px] underline hover:text-orange-300 block py-1 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer"
style={{ pointerEvents: 'all' }}
>
{headlines[idx] || url}
@@ -1917,19 +2100,19 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
anchor="bottom"
offset={15}
>
<div className="bg-black/90 backdrop-blur-md border border-yellow-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,255,0,0.3)] pointer-events-auto overflow-hidden w-[280px]">
<div className="bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-yellow-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,255,0,0.3)] pointer-events-auto overflow-hidden w-[280px]">
<div className="p-2 border-b border-yellow-500/30 bg-yellow-950/40 flex justify-between items-center">
<h2 className="text-[10px] tracking-widest font-bold text-yellow-400 flex items-center gap-1">
<AlertTriangle size={12} className="text-yellow-400" /> REGIONAL TACTICAL EVENT
</h2>
<button onClick={() => onEntityClick?.(null)} className="text-gray-400 hover:text-white"></button>
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"></button>
</div>
<div className="p-3 flex flex-col gap-2">
<div className="flex flex-col gap-1 border-b border-gray-800 pb-1">
<div className="flex flex-col gap-1 border-b border-[var(--border-primary)] pb-1">
<span className="text-yellow-400 text-[10px] font-bold leading-tight">{item.title}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-1 mt-1">
<span className="text-gray-500 text-[9px]">TIME</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1 mt-1">
<span className="text-[var(--text-muted)] text-[9px]">TIME</span>
<span className="text-white text-[9px] font-bold">{item.timestamp || 'UNKNOWN'}</span>
</div>
{item.link && (
@@ -1977,22 +2160,22 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
anchor="bottom"
offset={25}
>
<div className={`bg-black/90 backdrop-blur-md border ${borderColor} rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_${shadowColor}] pointer-events-auto overflow-hidden w-[280px]`}>
<div className={`bg-[var(--bg-secondary)]/90 backdrop-blur-md border ${borderColor} rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_${shadowColor}] pointer-events-auto overflow-hidden w-[280px]`}>
<div className={`p-2 border-b ${borderColor}/50 ${bgHeaderColor} flex justify-between items-center`}>
<h2 className={`text-[10px] tracking-widest font-bold ${threatColor} flex items-center gap-1`}>
<AlertTriangle size={12} className={threatColor} /> THREAT INTERCEPT
</h2>
<div className="flex items-center gap-2">
<span className={`text-[10px] ${threatColor} font-mono font-bold animate-pulse`}>LVL: {item.risk_score}/10</span>
<button onClick={() => onEntityClick?.(null)} className="text-gray-400 hover:text-white"></button>
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"></button>
</div>
</div>
<div className="p-3 flex flex-col gap-2">
<div className="flex flex-col gap-1 border-b border-gray-800 pb-1">
<div className="flex flex-col gap-1 border-b border-[var(--border-primary)] pb-1">
<span className={`text-[10px] font-bold leading-tight ${threatColor}`}>{item.title}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-1 mt-1">
<span className="text-gray-500 text-[9px]">SOURCE</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1 mt-1">
<span className="text-[var(--text-muted)] text-[9px]">SOURCE</span>
<span className="text-white text-[9px] font-bold text-right ml-2">{item.source || 'UNKNOWN'}</span>
</div>
{item.machine_assessment && (
@@ -2037,6 +2220,65 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Marker>
)}
{/* SENTINEL-2 IMAGERY — floating intel card on map near right-click */}
{selectedEntity?.type === 'region_dossier' && selectedEntity.extra && regionDossier?.sentinel2 && !regionDossierLoading && (
<Popup
longitude={selectedEntity.extra.lng}
latitude={selectedEntity.extra.lat}
anchor="top-left"
offset={[20, -10]}
closeButton={false}
closeOnClick={false}
className="sentinel-popup"
maxWidth="320px"
>
<div className="bg-black/90 backdrop-blur-md border border-blue-500/50 rounded-lg overflow-hidden shadow-[0_0_25px_rgba(59,130,246,0.3)] pointer-events-auto" style={{ width: 300 }}>
{/* Header bar */}
<div className="flex items-center justify-between px-3 py-1.5 bg-blue-950/60 border-b border-blue-500/30">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
<span className="text-[9px] text-blue-400 font-mono tracking-[0.2em] font-bold">SENTINEL-2 IMAGERY</span>
</div>
<span className="text-[8px] text-blue-300/60 font-mono">{selectedEntity.extra.lat.toFixed(3)}, {selectedEntity.extra.lng.toFixed(3)}</span>
</div>
{regionDossier.sentinel2.found ? (
<>
{/* Metadata row */}
<div className="flex items-center justify-between px-3 py-1.5 text-[9px] font-mono border-b border-blue-900/40">
<span className="text-blue-300">{regionDossier.sentinel2.platform}</span>
<span className="text-cyan-400 font-bold">{regionDossier.sentinel2.datetime?.slice(0, 10)}</span>
<span className="text-blue-300">{regionDossier.sentinel2.cloud_cover?.toFixed(0)}% cloud</span>
</div>
{/* Thumbnail */}
{regionDossier.sentinel2.thumbnail_url ? (
<a href={regionDossier.sentinel2.fullres_url || regionDossier.sentinel2.thumbnail_url} target="_blank" rel="noopener noreferrer">
<img
src={regionDossier.sentinel2.thumbnail_url}
alt="Sentinel-2 scene"
className="w-full block hover:brightness-110 transition-all cursor-pointer"
style={{ maxHeight: 220, objectFit: 'cover' }}
/>
</a>
) : (
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">Scene found no preview available</div>
)}
{/* Footer */}
<div className="px-3 py-1 bg-blue-950/40 text-[7px] text-blue-400/50 font-mono tracking-widest text-center">
CLICK IMAGE TO OPEN FULL RESOLUTION
</div>
</>
) : (
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">
No clear imagery in last 30 days
</div>
)}
</div>
</Popup>
)}
{/* MEASUREMENT LINES */}
{measurePoints && measurePoints.length >= 2 && (
<Source id="measure-lines" type="geojson" data={{
+7 -7
View File
@@ -15,15 +15,15 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
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"
className="w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.2)] 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"
className="flex justify-between items-center p-3 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
onClick={() => setIsMinimized(!isMinimized)}
>
<span className="text-[10px] text-gray-500 font-mono tracking-widest">GLOBAL MARKETS</span>
<button className="text-gray-500 hover:text-white transition-colors">
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -36,7 +36,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
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">
<div className="border-b border-[var(--border-primary)] 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>
@@ -45,7 +45,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: 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="text-[var(--text-primary)] 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)}%
@@ -65,7 +65,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: 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="text-[var(--text-primary)] 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)}%
+197 -164
View File
@@ -3,9 +3,43 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle, Clock, ChevronDown, ChevronUp } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useCallback } from 'react';
import Hls from 'hls.js';
import WikiImage from '@/components/WikiImage';
// HLS video player — uses hls.js on Chrome/Firefox, native on Safari
function HlsVideo({ url, className }: { url: string; className?: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video || !url) return;
let hls: Hls | null = null;
if (Hls.isSupported()) {
hls = new Hls({ enableWorker: false, lowLatencyMode: true });
hls.loadSource(url);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS
video.src = url;
}
return () => { hls?.destroy(); };
}, [url]);
return (
<video
ref={videoRef}
autoPlay
muted
playsInline
className={className}
/>
);
}
// Format time from pubish string "Tue, 24 Feb 2026 15:30:00 GMT" to "15:30"
function formatTime(pubDate: string) {
try {
@@ -165,7 +199,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
>
<div className="p-3 border-b border-emerald-500/30 bg-emerald-950/40 flex justify-between items-center">
<h2 className="text-xs tracking-widest font-bold text-emerald-400">REGION DOSSIER</h2>
<span className="text-[8px] text-gray-500">
<span className="text-[8px] text-[var(--text-muted)]">
{selectedEntity.extra ? `${selectedEntity.extra.lat.toFixed(3)}, ${selectedEntity.extra.lng.toFixed(3)}` : ''}
</span>
</div>
@@ -177,41 +211,43 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<div className="p-3 flex flex-col gap-1.5 max-h-[500px] overflow-y-auto styled-scrollbar text-[10px]">
{/* COUNTRY */}
<div className="text-[9px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1">COUNTRY LEVEL {d.country?.flag_emoji || ''}</div>
<div className="flex justify-between"><span className="text-gray-500">COUNTRY</span><span className="text-white font-bold">{d.country?.name}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">COUNTRY</span><span className="text-[var(--text-primary)] font-bold">{d.country?.name}</span></div>
{d.country?.official_name && d.country.official_name !== d.country.name && (
<div className="flex justify-between"><span className="text-gray-500">OFFICIAL</span><span className="text-gray-300 text-right max-w-[180px]">{d.country.official_name}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">OFFICIAL</span><span className="text-[var(--text-secondary)] text-right max-w-[180px]">{d.country.official_name}</span></div>
)}
<div className="flex justify-between"><span className="text-gray-500">LEADER</span><span className="text-emerald-400 font-bold">{d.country?.leader}</span></div>
<div className="flex justify-between"><span className="text-gray-500">GOVERNMENT</span><span className="text-white font-bold text-right max-w-[180px]">{d.country?.government_type}</span></div>
<div className="flex justify-between"><span className="text-gray-500">POPULATION</span><span className="text-white font-bold">{d.country?.population?.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-gray-500">CAPITAL</span><span className="text-white font-bold">{d.country?.capital}</span></div>
<div className="flex justify-between"><span className="text-gray-500">LANGUAGES</span><span className="text-white text-right max-w-[180px]">{d.country?.languages?.join(', ')}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">LEADER</span><span className="text-emerald-400 font-bold">{d.country?.leader}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">GOVERNMENT</span><span className="text-[var(--text-primary)] font-bold text-right max-w-[180px]">{d.country?.government_type}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">POPULATION</span><span className="text-[var(--text-primary)] font-bold">{d.country?.population?.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">CAPITAL</span><span className="text-[var(--text-primary)] font-bold">{d.country?.capital}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">LANGUAGES</span><span className="text-[var(--text-primary)] text-right max-w-[180px]">{d.country?.languages?.join(', ')}</span></div>
{d.country?.currencies?.length > 0 && (
<div className="flex justify-between"><span className="text-gray-500">CURRENCY</span><span className="text-white text-right max-w-[180px]">{d.country.currencies.join(', ')}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">CURRENCY</span><span className="text-[var(--text-primary)] text-right max-w-[180px]">{d.country.currencies.join(', ')}</span></div>
)}
<div className="flex justify-between"><span className="text-gray-500">REGION</span><span className="text-white">{d.country?.subregion || d.country?.region}</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">REGION</span><span className="text-[var(--text-primary)]">{d.country?.subregion || d.country?.region}</span></div>
{d.country?.area_km2 > 0 && (
<div className="flex justify-between"><span className="text-gray-500">AREA</span><span className="text-white">{d.country.area_km2.toLocaleString()} km²</span></div>
<div className="flex justify-between"><span className="text-[var(--text-muted)]">AREA</span><span className="text-[var(--text-primary)]">{d.country.area_km2.toLocaleString()} km²</span></div>
)}
{/* LOCAL */}
{(d.local?.name || d.local?.state) && (
<>
<div className="text-[9px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1 mt-2">LOCAL LEVEL</div>
{d.local.name && <div className="flex justify-between"><span className="text-gray-500">LOCALITY</span><span className="text-white font-bold">{d.local.name}</span></div>}
{d.local.state && <div className="flex justify-between"><span className="text-gray-500">STATE/PROVINCE</span><span className="text-white font-bold">{d.local.state}</span></div>}
{d.local.description && <div className="flex justify-between"><span className="text-gray-500">TYPE</span><span className="text-gray-300">{d.local.description}</span></div>}
{d.local.name && <div className="flex justify-between"><span className="text-[var(--text-muted)]">LOCALITY</span><span className="text-[var(--text-primary)] font-bold">{d.local.name}</span></div>}
{d.local.state && <div className="flex justify-between"><span className="text-[var(--text-muted)]">STATE/PROVINCE</span><span className="text-[var(--text-primary)] font-bold">{d.local.state}</span></div>}
{d.local.description && <div className="flex justify-between"><span className="text-[var(--text-muted)]">TYPE</span><span className="text-[var(--text-secondary)]">{d.local.description}</span></div>}
{d.local.summary && (
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 rounded text-[9px] text-gray-300 leading-relaxed">
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 rounded text-[9px] text-[var(--text-secondary)] leading-relaxed">
<span className="text-emerald-400 font-bold">&gt;_ INTEL: </span>
{d.local.summary.length > 500 ? d.local.summary.substring(0, 500) + '...' : d.local.summary}
</div>
)}
</>
)}
{/* Sentinel-2 imagery now shown as map popup — see MaplibreViewer */}
</div>
) : d?.error ? (
<div className="p-4 text-gray-400 text-[10px]">{d.error}</div>
<div className="p-4 text-[var(--text-secondary)] text-[10px]">{d.error}</div>
) : (
<div className="p-4 text-red-400 text-[10px]">INTEL UNAVAILABLE</div>
)}
@@ -229,34 +265,34 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
};
const alertBorderMap: Record<string, string> = {
'pink': 'border-pink-500/30', 'red': 'border-red-500/30',
'darkblue': 'border-blue-500/30', 'white': 'border-gray-500/30'
'darkblue': 'border-blue-500/30', 'white': 'border-[var(--border-primary)]/30'
};
const alertBgMap: Record<string, string> = {
'pink': 'bg-pink-950/40', 'red': 'bg-red-950/40',
'darkblue': 'bg-blue-950/40', 'white': 'bg-gray-900/40'
'darkblue': 'bg-blue-950/40', 'white': 'bg-[var(--bg-panel)]'
};
const ac = flight.alert_color || 'white';
const headerColor = alertColorMap[ac] || 'text-white';
const borderColor = alertBorderMap[ac] || 'border-gray-500/30';
const bgColor = alertBgMap[ac] || 'bg-gray-900/40';
const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30';
const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]';
return (
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4 }}
className={`w-full bg-black/60 backdrop-blur-md border ${ac === 'pink' ? 'border-pink-800' : ac === 'red' ? 'border-red-800' : ac === 'darkblue' ? 'border-blue-800' : 'border-gray-600'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,20,147,0.2)] pointer-events-auto overflow-hidden flex-shrink-0`}
className={`w-full bg-black/60 backdrop-blur-md border ${ac === 'pink' ? 'border-pink-800' : ac === 'red' ? 'border-red-800' : ac === 'darkblue' ? 'border-blue-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,20,147,0.2)] pointer-events-auto overflow-hidden flex-shrink-0`}
>
<div className={`p-3 border-b ${borderColor} ${bgColor} flex justify-between items-center`}>
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
TRACKED AIRCRAFT {flight.alert_category || "ALERT"}
</h2>
<span className="text-[10px] text-gray-500 font-mono">TRK: {callsign}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">TRK: {callsign}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">OPERATOR</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (
<a
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
@@ -273,7 +309,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
</div>
{/* Owner/Operator Wikipedia photo */}
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (
<div className="border-b border-gray-800 pb-2">
<div className="border-b border-[var(--border-primary)] pb-2">
<WikiImage
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
label={flight.alert_operator}
@@ -284,12 +320,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
{/* Aircraft model Wikipedia photo */}
{aircraftImgUrl && (
<div className="border-b border-gray-800 pb-2">
<div className="border-b border-[var(--border-primary)] pb-2">
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
<img
src={aircraftImgUrl}
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
className={`w-full h-auto max-h-28 object-cover rounded border border-gray-700/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
className={`w-full h-auto max-h-28 object-cover rounded border border-[var(--border-primary)]/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
/>
</a>
{aircraftWikiUrl && (
@@ -300,65 +336,65 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">CATEGORY</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">CATEGORY</span>
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_category || "N/A"}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">AIRCRAFT</span>
<span className="text-white text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
<span className="text-white text-xs font-bold">{flight.registration || "N/A"}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
</div>
{flight.alert_tag1 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">INTEL TAG</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAG</span>
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_tag1}</span>
</div>
)}
{flight.alert_tag2 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SECONDARY</span>
<span className="text-white text-xs font-bold">{flight.alert_tag2}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SECONDARY</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_tag2}</span>
</div>
)}
{flight.alert_tag3 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">DETAIL</span>
<span className="text-gray-400 text-xs">{flight.alert_tag3}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">DETAIL</span>
<span className="text-[var(--text-secondary)] text-xs">{flight.alert_tag3}</span>
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
<span className="text-white text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
<span className="text-white text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">HEADING</span>
<span className="text-white text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
</div>
{flight.squawk && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SQUAWK</span>
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-white'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SQUAWK</span>
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
</div>
)}
{flight.alert_link && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">REFERENCE</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REFERENCE</span>
<a href={flight.alert_link} target="_blank" rel="noreferrer" className={`text-xs font-bold underline ${headerColor} hover:opacity-80`}>
View Intel Source
</a>
</div>
)}
{flight.icao24 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">FLIGHT RECORD</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
<a href={`https://adsb.lol/?icao=${flight.icao24}`} target="_blank" rel="noreferrer" className={`${headerColor} hover:opacity-80 text-xs font-bold underline`}>
View History Log
</a>
@@ -417,34 +453,34 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className={`text-xs tracking-widest font-bold ${selectedEntity.type === 'military_flight' ? 'text-red-400' : selectedEntity.type === 'private_flight' ? 'text-orange-400' : selectedEntity.type === 'private_jet' ? 'text-purple-400' : 'text-cyan-400'} flex items-center gap-2`}>
{selectedEntity.type === 'military_flight' ? "MILITARY BOGEY INTERCEPT" : selectedEntity.type === 'private_flight' ? "PRIVATE TRANSPONDER" : selectedEntity.type === 'private_jet' ? "PRIVATE JET TRANSPONDER" : "COMMERCIAL TRANSPONDER"}
</h2>
<span className="text-[10px] text-gray-500 font-mono">TRK: {callsign}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">TRK: {callsign}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">OPERATOR</span>
<span className="text-white text-xs font-bold">{airline}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{airline}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
<span className="text-white text-xs font-bold">{flight.registration || "N/A"}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">AIRCRAFT MODEL</span>
<span className="text-white text-xs font-bold">{flight.model || "UNKNOWN"}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT MODEL</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
</div>
{/* Aircraft photo + Wikipedia link */}
{(aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
<div className="border-b border-gray-800 pb-3">
<div className="border-b border-[var(--border-primary)] pb-3">
{aircraftImgLoading && (
<div className="w-full h-24 rounded bg-gray-800/60 animate-pulse" />
<div className="w-full h-24 rounded bg-[var(--bg-tertiary)]/60 animate-pulse" />
)}
{aircraftImgUrl && (
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
<img
src={aircraftImgUrl}
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
className="w-full h-auto max-h-32 object-cover rounded border border-gray-700/50 hover:border-cyan-500/50 transition-colors"
className="w-full h-auto max-h-32 object-cover rounded border border-[var(--border-primary)]/50 hover:border-cyan-500/50 transition-colors"
style={{ imageRendering: 'auto' }}
/>
</a>
@@ -457,31 +493,31 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
<span className="text-white text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
<span className="text-white text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">HEADING</span>
<span className="text-white text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
</div>
{flight.squawk && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SQUAWK</span>
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-white'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SQUAWK</span>
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">ROUTE</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">ROUTE</span>
<span className="text-cyan-400 text-xs font-bold">{flight.origin_name !== "UNKNOWN" ? `[${flight.origin_name}] → [${flight.dest_name}]` : "UNKNOWN"}</span>
</div>
{flight.icao24 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">FLIGHT RECORD</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
<a href={`https://adsb.lol/?icao=${flight.icao24}`} target="_blank" rel="noreferrer" className="text-cyan-400 hover:text-cyan-300 text-xs font-bold underline">
View History Log
</a>
@@ -514,7 +550,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
'military_vessel': 'text-yellow-400',
'carrier': 'text-orange-400',
};
const headerColor = headerColorMap[ship.type] || 'text-gray-400';
const headerColor = headerColorMap[ship.type] || 'text-[var(--text-secondary)]';
const headerTitleMap: Record<string, string> = {
'tanker': 'AIS TANKER INTERCEPT',
@@ -537,49 +573,49 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
{headerTitle}
</h2>
<span className="text-[10px] text-gray-500 font-mono">MMSI: {ship.mmsi || 'N/A'}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">MMSI: {ship.mmsi || 'N/A'}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">VESSEL NAME</span>
<span className="text-white text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">VESSEL NAME</span>
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">VESSEL TYPE</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">VESSEL TYPE</span>
<span className={`text-xs font-bold ${headerColor}`}>{typeLabel}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">FLAG STATE</span>
<span className="text-white text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FLAG STATE</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
</div>
{ship.callsign && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">CALLSIGN</span>
<span className="text-white text-xs font-bold">{ship.callsign}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">CALLSIGN</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.callsign}</span>
</div>
)}
{ship.imo > 0 && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">IMO NUMBER</span>
<span className="text-white text-xs font-bold">{ship.imo}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">IMO NUMBER</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.imo}</span>
</div>
)}
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">DESTINATION</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">DESTINATION</span>
<span className={`text-xs font-bold ${ship.destination && ship.destination !== 'UNKNOWN' ? 'text-cyan-400' : 'text-orange-400'}`}>{ship.destination || 'UNKNOWN'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SPEED (SOG)</span>
<span className="text-white text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${ship.sog || 0} kts`}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SPEED (SOG)</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${ship.sog || 0} kts`}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">COURSE (COG)</span>
<span className="text-white text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${Math.round(ship.cog || 0)}°`}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">COURSE (COG)</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${Math.round(ship.cog || 0)}°`}</span>
</div>
{ship.mmsi && (
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">VESSEL RECORD</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">VESSEL RECORD</span>
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`} target="_blank" rel="noreferrer" className="text-cyan-400 hover:text-cyan-300 text-xs font-bold underline">
View on MarineTraffic
</a>
@@ -587,7 +623,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
{/* Ship/Carrier Wikipedia photo */}
{(ship.wiki || VESSEL_TYPE_WIKI[ship.type]) && (
<div className="border-t border-gray-800 pt-2">
<div className="border-t border-[var(--border-primary)] pt-2">
<WikiImage
wikiUrl={ship.wiki || VESSEL_TYPE_WIKI[ship.type]}
label={ship.type === 'carrier' ? ship.name : typeLabel}
@@ -617,22 +653,22 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className="text-xs tracking-widest font-bold text-orange-400 flex items-center gap-2">
<AlertTriangle size={14} className="text-orange-400" /> MILITARY INCIDENT CLUSTER
</h2>
<span className="text-[10px] text-gray-500 font-mono">ID: {selectedEntity.id}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">ID: {selectedEntity.id}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">LOCATION</span>
<span className="text-white text-xs font-bold text-right ml-4">{props.name || 'UNKNOWN REGION'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">LOCATION</span>
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{props.name || 'UNKNOWN REGION'}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">ARTICLE COUNT</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">ARTICLE COUNT</span>
<span className="text-orange-400 text-xs font-bold">{props.count || 1}</span>
</div>
<div className="flex flex-col gap-2 mt-2">
<span className="text-gray-500 text-[10px]">LATEST REPORTS:</span>
<span className="text-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
<div
className="text-white text-xs whitespace-normal [&_a]:text-orange-400 [&_a]:underline hover:[&_a]:text-orange-300 [&_br]:mb-2"
className="text-[var(--text-primary)] text-xs whitespace-normal [&_a]:text-orange-400 [&_a]:underline hover:[&_a]:text-orange-300 [&_br]:mb-2"
dangerouslySetInnerHTML={{ __html: props.html || 'No articles available.' }}
/>
</div>
@@ -656,25 +692,25 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className="text-xs tracking-widest font-bold text-yellow-400 flex items-center gap-2">
<AlertTriangle size={14} className="text-yellow-400" /> REGIONAL TACTICAL EVENT
</h2>
<span className="text-[10px] text-gray-500 font-mono">ID: {item.id}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">ID: {item.id}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">REGION</span>
<span className="text-white text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REGION</span>
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
</div>
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">DESCRIPTION</span>
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">DESCRIPTION</span>
<span className="text-yellow-400 text-xs font-bold leading-tight">{item.title}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2 mt-2">
<span className="text-gray-500 text-[10px]">REPORTED TIME</span>
<span className="text-white text-xs font-bold">{item.timestamp || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2 mt-2">
<span className="text-[var(--text-muted)] text-[10px]">REPORTED TIME</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{item.timestamp || 'UNKNOWN'}</span>
</div>
{item.link && (
<div className="flex justify-between items-center pb-2 mt-2">
<span className="text-gray-500 text-[10px]">SOURCE</span>
<span className="text-[var(--text-muted)] text-[10px]">SOURCE</span>
<a href={item.link} target="_blank" rel="noreferrer" className="text-yellow-400 hover:text-yellow-300 text-xs font-bold underline">
View Liveuamap Report
</a>
@@ -700,16 +736,16 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className="text-xs tracking-widest font-bold text-red-400 flex items-center gap-2">
<AlertTriangle size={14} className="text-red-400" /> THREAT INTERCEPT
</h2>
<span className="text-[10px] text-gray-500 font-mono">LVL: {item.risk_score}/10</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">LVL: {item.risk_score}/10</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">SOURCE</span>
<span className="text-white text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SOURCE</span>
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
</div>
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">HEADLINE</span>
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">HEADLINE</span>
<span className="text-red-400 text-xs font-bold leading-tight">{item.title}</span>
</div>
{item.machine_assessment && (
@@ -721,7 +757,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
)}
{item.link && (
<div className="flex justify-between items-center pb-2 mt-2">
<span className="text-gray-500 text-[10px]">REFERENCE</span>
<span className="text-[var(--text-muted)] text-[10px]">REFERENCE</span>
<a href={item.link} target="_blank" rel="noreferrer" className="text-red-400 hover:text-red-300 text-xs font-bold underline">
View Source Article
</a>
@@ -747,20 +783,20 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
AERONAUTICAL HUB
</h2>
<span className="text-[10px] text-gray-500 font-mono">IATA: {apt.iata}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">IATA: {apt.iata}</span>
</div>
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">FACILITY NAME</span>
<span className="text-white text-[10px] font-bold text-right ml-4 break-words">{apt.name}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FACILITY NAME</span>
<span className="text-[var(--text-primary)] text-[10px] font-bold text-right ml-4 break-words">{apt.name}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">COORDINATES</span>
<span className="text-white text-xs font-bold">{apt.lat.toFixed(4)}, {apt.lng.toFixed(4)}</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">COORDINATES</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{apt.lat.toFixed(4)}, {apt.lng.toFixed(4)}</span>
</div>
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
<span className="text-gray-500 text-[10px]">STATUS</span>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">STATUS</span>
<span className="text-green-400 animate-pulse text-xs font-bold">OPERATIONAL</span>
</div>
</div>
@@ -783,7 +819,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
? new Date(selectedEntity.extra.last_updated + 'Z').toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' }).toUpperCase() + ' — OPTIC INTERCEPT'
: 'OPTIC INTERCEPT'}
</h2>
<span className="text-[10px] text-gray-500 font-mono">ID: {selectedEntity.id}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">ID: {selectedEntity.id}</span>
</div>
<div className="relative w-full h-48 bg-black flex items-center justify-center p-1">
{(() => {
@@ -807,11 +843,8 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
/>
);
if (mt === 'hls') return (
<video
src={url}
autoPlay
muted
playsInline
<HlsVideo
url={url}
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
/>
);
@@ -870,7 +903,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className={`w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
className={`w-full bg-[var(--bg-panel)] backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
>
<div
className="p-3 border-b border-cyan-500/20 bg-cyan-950/20 relative overflow-hidden cursor-pointer hover:bg-cyan-900/30 transition-colors"
@@ -880,7 +913,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
<AlertTriangle size={14} /> GLOBAL THREAT INTERCEPT
</h2>
<button className="text-cyan-500 hover:text-white transition-colors">
<button className="text-cyan-500 hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -938,14 +971,14 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
transition={{ delay: 0.1 + (idx * 0.05) }}
className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${bgClass} flex flex-col gap-1 relative group shrink-0`}
>
<div className="flex items-center justify-between text-[8px] text-gray-400 uppercase tracking-widest">
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
<span className="font-bold flex items-center gap-1 text-cyan-600">
&gt;_ {item.source}
</span>
<span>[{item.published ? formatTime(item.published) : ''}]</span>
</div>
<a href={item.link} target="_blank" rel="noreferrer" className={`text-[11px] ${titleClass} hover:text-white transition-colors leading-tight`}>
<a href={item.link} target="_blank" rel="noreferrer" className={`text-[11px] ${titleClass} hover:text-[var(--text-primary)] transition-colors leading-tight`}>
{item.title}
</a>
@@ -963,12 +996,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
</span>
<div className="flex items-center gap-2">
{item.cluster_count > 1 && (
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-cyan-950/50 hover:text-white hover:bg-cyan-900 border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-cyan-950/50 hover:text-[var(--text-primary)] hover:bg-cyan-900 border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
{isExpanded ? '[- COLLAPSE]' : `[+${item.cluster_count - 1} SOURCES]`}
</button>
)}
{item.coords && (
<span className="text-[8px] text-gray-500 font-mono tracking-tighter">
<span className="text-[8px] text-[var(--text-muted)] font-mono tracking-tighter">
{item.coords[0].toFixed(2)}, {item.coords[1].toFixed(2)}
</span>
)}
@@ -985,7 +1018,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
>
{item.articles.slice(1).map((subItem: any, subIdx: number) => (
<div key={subIdx} className="flex flex-col gap-0.5 pl-2 border-l border-cyan-500/20">
<div className="flex items-center justify-between text-[7.5px] text-gray-500 uppercase font-bold">
<div className="flex items-center justify-between text-[7.5px] text-[var(--text-muted)] uppercase font-bold">
<span>&gt;_ {subItem.source}</span>
<span className={
subItem.risk_score >= 9 ? 'text-red-400' :
@@ -994,7 +1027,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
'text-green-400'
}>LVL: {subItem.risk_score}/10</span>
</div>
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-gray-400 hover:text-white transition-colors leading-tight">
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors leading-tight">
{subItem.title}
</a>
</div>
+290
View File
@@ -0,0 +1,290 @@
"use client";
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio } from "lucide-react";
const STORAGE_KEY = "shadowbroker_onboarding_complete";
const API_GUIDES = [
{
name: "OpenSky Network",
icon: <Radar size={14} className="text-cyan-400" />,
required: true,
description: "Flight tracking with global ADS-B coverage. Provides real-time aircraft positions.",
steps: [
"Create a free account at opensky-network.org",
"Go to Dashboard → OAuth → Create Client",
"Copy your Client ID and Client Secret",
"Paste both into Settings → Aviation",
],
url: "https://opensky-network.org/index.php?option=com_users&view=registration",
color: "cyan",
},
{
name: "AIS Stream",
icon: <Ship size={14} className="text-blue-400" />,
required: true,
description: "Real-time vessel tracking via AIS (Automatic Identification System).",
steps: [
"Register at aisstream.io",
"Navigate to your API Keys page",
"Generate a new API key",
"Paste it into Settings → Maritime",
],
url: "https://aisstream.io/authenticate",
color: "blue",
},
];
const FREE_SOURCES = [
{ name: "ADS-B Exchange", desc: "Military & general aviation", icon: <Radar size={12} /> },
{ name: "USGS Earthquakes", desc: "Global seismic data", icon: <Globe size={12} /> },
{ name: "CelesTrak", desc: "2,000+ satellite orbits", icon: <Satellite size={12} /> },
{ name: "GDELT Project", desc: "Global conflict events", icon: <Globe size={12} /> },
{ name: "RainViewer", desc: "Weather radar overlay", icon: <Globe size={12} /> },
{ name: "OpenMHz", desc: "Radio scanner feeds", icon: <Radio size={12} /> },
{ name: "RSS Feeds", desc: "NPR, BBC, Reuters, AP", icon: <Globe size={12} /> },
{ name: "Yahoo Finance", desc: "Defense stocks & oil", icon: <Globe size={12} /> },
];
interface OnboardingModalProps {
onClose: () => void;
onOpenSettings: () => void;
}
const OnboardingModal = React.memo(function OnboardingModal({ onClose, onOpenSettings }: OnboardingModalProps) {
const [step, setStep] = useState(0);
const handleDismiss = () => {
localStorage.setItem(STORAGE_KEY, "true");
onClose();
};
const handleOpenSettings = () => {
localStorage.setItem(STORAGE_KEY, "true");
onClose();
onOpenSettings();
};
return (
<AnimatePresence>
{/* Backdrop */}
<motion.div
key="onboarding-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
onClick={handleDismiss}
/>
{/* Modal */}
<motion.div
key="onboarding-modal"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
>
<div
className="w-[580px] max-h-[85vh] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 rounded-xl shadow-[0_0_80px_rgba(0,200,255,0.08)] pointer-events-auto flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-6 pb-4 border-b border-[var(--border-primary)]/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
<Shield size={20} className="text-cyan-400" />
</div>
<div>
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">MISSION BRIEFING</h2>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">FIRST-TIME SETUP</span>
</div>
</div>
<button
onClick={handleDismiss}
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
</div>
</div>
{/* Step Indicators */}
<div className="flex gap-2 px-6 pt-4">
{["Welcome", "API Keys", "Free Sources"].map((label, i) => (
<button
key={label}
onClick={() => setStep(i)}
className={`flex-1 py-1.5 text-[9px] font-mono tracking-widest rounded border transition-all ${
step === i
? "border-cyan-500/50 text-cyan-400 bg-cyan-950/20"
: "border-[var(--border-primary)] text-[var(--text-muted)] hover:border-[var(--border-secondary)] hover:text-[var(--text-secondary)]"
}`}
>
{label.toUpperCase()}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
{step === 0 && (
<div className="space-y-4">
<div className="text-center py-4">
<div className="text-lg font-bold tracking-[0.3em] text-[var(--text-primary)] font-mono mb-2">
S H A D O W <span className="text-cyan-400">B R O K E R</span>
</div>
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed max-w-md mx-auto">
Real-time OSINT dashboard aggregating 12+ live intelligence sources.
Flights, ships, satellites, earthquakes, conflicts, and more all on one map.
</p>
</div>
<div className="bg-yellow-950/20 border border-yellow-500/20 rounded-lg p-4">
<div className="flex items-start gap-2">
<Key size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-[11px] text-yellow-400 font-mono font-bold mb-1">API Keys Required</p>
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
Two API keys are needed for full functionality: <span className="text-cyan-400">OpenSky Network</span> (flights) and <span className="text-blue-400">AIS Stream</span> (ships).
Both are free. Without them, some panels will show no data.
</p>
</div>
</div>
</div>
<div className="bg-green-950/20 border border-green-500/20 rounded-lg p-4">
<div className="flex items-start gap-2">
<Globe size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-[11px] text-green-400 font-mono font-bold mb-1">8 Sources Work Immediately</p>
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
Military aircraft, satellites, earthquakes, global conflicts, weather radar, radio scanners, news, and market data all work out of the box no keys needed.
</p>
</div>
</div>
</div>
</div>
)}
{step === 1 && (
<div className="space-y-4">
{API_GUIDES.map((api) => (
<div key={api.name} className={`rounded-lg border border-${api.color}-900/30 bg-${api.color}-950/10 p-4`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{api.icon}
<span className="text-xs font-mono text-white font-bold">{api.name}</span>
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">REQUIRED</span>
</div>
<a
href={api.url}
target="_blank"
rel="noopener noreferrer"
className={`text-[10px] font-mono text-${api.color}-400 hover:text-${api.color}-300 flex items-center gap-1 transition-colors`}
>
GET KEY <ExternalLink size={10} />
</a>
</div>
<p className="text-[10px] text-[var(--text-secondary)] font-mono mb-3">{api.description}</p>
<ol className="space-y-1.5">
{api.steps.map((s, i) => (
<li key={i} className="flex items-start gap-2">
<span className={`text-[9px] font-mono text-${api.color}-500 font-bold mt-0.5 w-3 flex-shrink-0`}>{i + 1}.</span>
<span className="text-[10px] text-gray-300 font-mono">{s}</span>
</li>
))}
</ol>
</div>
))}
<button
onClick={handleOpenSettings}
className="w-full py-3 rounded-lg bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/20 transition-colors text-[11px] font-mono tracking-widest flex items-center justify-center gap-2"
>
<Key size={14} />
OPEN SETTINGS TO ENTER KEYS
</button>
</div>
)}
{step === 2 && (
<div className="space-y-3">
<p className="text-[10px] text-[var(--text-secondary)] font-mono mb-3">
These data sources are completely free and require no API keys. They activate automatically on launch.
</p>
<div className="grid grid-cols-2 gap-2">
{FREE_SOURCES.map((src) => (
<div key={src.name} className="rounded-lg border border-[var(--border-primary)]/60 bg-[var(--bg-secondary)]/30 p-3 hover:border-[var(--border-secondary)] transition-colors">
<div className="flex items-center gap-2 mb-1">
<span className="text-green-500">{src.icon}</span>
<span className="text-[10px] font-mono text-[var(--text-primary)] font-medium">{src.name}</span>
</div>
<p className="text-[9px] text-[var(--text-muted)] font-mono">{src.desc}</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-[var(--border-primary)]/80 flex items-center justify-between">
<button
onClick={() => setStep(Math.max(0, step - 1))}
className={`px-4 py-2 rounded border text-[10px] font-mono tracking-widest transition-all ${
step === 0
? "border-[var(--border-primary)] text-[var(--text-muted)] cursor-not-allowed"
: "border-[var(--border-primary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)]"
}`}
disabled={step === 0}
>
PREV
</button>
<div className="flex gap-1.5">
{[0, 1, 2].map((i) => (
<div key={i} className={`w-1.5 h-1.5 rounded-full transition-colors ${step === i ? "bg-cyan-400" : "bg-[var(--border-primary)]"}`} />
))}
</div>
{step < 2 ? (
<button
onClick={() => setStep(step + 1)}
className="px-4 py-2 rounded border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/10 text-[10px] font-mono tracking-widest transition-all"
>
NEXT
</button>
) : (
<button
onClick={handleDismiss}
className="px-4 py-2 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 text-[10px] font-mono tracking-widest transition-all"
>
LAUNCH
</button>
)}
</div>
</div>
</motion.div>
</AnimatePresence>
);
});
export function useOnboarding() {
const [showOnboarding, setShowOnboarding] = useState(false);
useEffect(() => {
const done = localStorage.getItem(STORAGE_KEY);
if (!done) {
setShowOnboarding(true);
}
}, []);
return { showOnboarding, setShowOnboarding };
}
export default OnboardingModal;
+43 -12
View File
@@ -1,10 +1,11 @@
"use client";
import { API_BASE } from "@/lib/api";
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 }) {
export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesdropping, eavesdropLocation, cameraCenter, selectedEntity }: { data: any, isEavesdropping?: boolean, setIsEavesdropping?: (val: boolean) => void, eavesdropLocation?: { lat: number, lng: number } | null, cameraCenter?: { lat: number, lng: number } | null, selectedEntity?: { type: string, id: string | number, extra?: any } | null }) {
const [isMinimized, setIsMinimized] = useState(true);
const [feeds, setFeeds] = useState<any[]>([]);
const [activeFeed, setActiveFeed] = useState<any | null>(null);
@@ -18,7 +19,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
useEffect(() => {
const fetchFeeds = async () => {
try {
const res = await fetch("http://localhost:8000/api/radio/top");
const res = await fetch(`${API_BASE}/api/radio/top`);
if (res.ok) {
const json = await res.json();
setFeeds(json);
@@ -47,12 +48,12 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
category: 'SIGINT'
}, ...prev]);
const res = await fetch(`http://localhost:8000/api/radio/nearest?lat=${eavesdropLocation.lat}&lng=${eavesdropLocation.lng}`);
const res = await fetch(`${API_BASE}/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}`);
const callRes = await fetch(`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`);
if (callRes.ok) {
const calls = await callRes.json();
if (calls && calls.length > 0) {
@@ -189,14 +190,14 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
if (scanLoc) {
try {
const res = await fetch(`http://localhost:8000/api/radio/nearest-list?lat=${scanLoc.lat}&lng=${scanLoc.lng}&limit=3`);
const res = await fetch(`${API_BASE}/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}`);
const callRes = await fetch(`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`);
if (callRes.ok) {
const calls = await callRes.json();
if (calls && calls.length > 0) {
@@ -248,7 +249,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 1, delay: 0.2 }}
className="w-full flex flex-col bg-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"
className="w-full flex flex-col bg-[var(--bg-primary)]/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] relative overflow-hidden max-h-full"
>
<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"
@@ -273,13 +274,13 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
className="flex flex-col overflow-hidden"
>
{/* Audio Player Controls */}
<div className="p-4 border-b border-cyan-900/40 bg-black/60">
<div className="p-4 border-b border-cyan-900/40 bg-[var(--bg-primary)]/60">
<div className="flex items-center justify-between mb-3">
<div className="flex flex-col">
<span className="text-xs text-cyan-300 font-mono tracking-wide">
{activeFeed ? activeFeed.name : "NO SIGNAL"}
</span>
<span className="text-[9px] text-gray-500 font-mono">
<span className="text-[9px] text-[var(--text-muted)] font-mono">
{activeFeed ? `LOCATION: ${activeFeed.location.toUpperCase()}` : "AWAITING TUNING..."}
</span>
</div>
@@ -346,6 +347,36 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
</div>
</div>
{/* KiwiSDR Tuner — appears when a KiwiSDR node is clicked on the map */}
{selectedEntity?.type === 'kiwisdr' && selectedEntity.extra?.url && (
<div className="p-3 border-b border-amber-900/40 bg-amber-950/10">
<div className="text-[9px] text-amber-400 font-mono tracking-widest mb-2 flex items-center gap-2">
<RadioReceiver size={10} />
SDR TUNER: {(selectedEntity.extra.name || 'REMOTE RECEIVER').toUpperCase().slice(0, 60)}
</div>
<div className="text-[8px] text-[var(--text-muted)] font-mono mb-2">
{selectedEntity.extra.location && <span>{selectedEntity.extra.location} · </span>}
{selectedEntity.extra.antenna && <span>{selectedEntity.extra.antenna.slice(0, 80)} · </span>}
{selectedEntity.extra.users !== undefined && <span>{selectedEntity.extra.users}/{selectedEntity.extra.users_max} users</span>}
</div>
<iframe
src={selectedEntity.extra.url}
className="w-full h-72 rounded border border-amber-900/50 bg-black"
allow="microphone"
sandbox="allow-scripts allow-same-origin"
title="KiwiSDR Tuner"
/>
<a
href={selectedEntity.extra.url}
target="_blank"
rel="noopener noreferrer"
className="text-[8px] text-amber-500 hover:text-amber-300 font-mono mt-1 inline-block"
>
OPEN IN NEW TAB
</a>
</div>
)}
{/* Feed List */}
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
{feeds.length === 0 ? (
@@ -358,10 +389,10 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
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'}`}>
<span className={`text-[11px] font-mono truncate ${activeFeed?.id === feed.id ? 'text-cyan-300' : 'text-[var(--text-secondary)]'}`}>
{feed.name}
</span>
<span className="text-[9px] text-gray-500 font-mono truncate">
<span className="text-[9px] text-[var(--text-muted)] font-mono truncate">
{feed.location} | {feed.category}
</span>
</div>
@@ -370,7 +401,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
<Activity size={10} />
{feed.listeners.toLocaleString()}
</span>
<span className="text-[8px] text-gray-600 font-mono mt-0.5">LSTN</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">LSTN</span>
</div>
</div>
))
+4 -4
View File
@@ -136,7 +136,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
{/* 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"
className="text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-[var(--border-primary)] hover:border-cyan-500/50 text-[var(--text-muted)] 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"}
@@ -147,7 +147,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
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"
: "border-[var(--border-primary)] text-[var(--text-muted)] 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)"}
>
@@ -159,7 +159,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
{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"
className="flex items-center gap-1 text-[8px] font-mono tracking-widest px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/20 transition-all"
title="Clear all waypoints"
>
<Trash2 size={10} />
@@ -172,7 +172,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
{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"
: "border-[var(--border-primary)] text-[var(--text-secondary)]"
}`}>
{d}
</span>
+36 -74
View File
@@ -1,8 +1,9 @@
"use client";
import { API_BASE } from "@/lib/api";
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";
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
interface ApiEntry {
id: string;
@@ -14,7 +15,7 @@ interface ApiEntry {
has_key: boolean;
env_key: string | null;
value_obfuscated: string | null;
value_plain: string | null;
is_set: boolean;
}
// Category colors for the tactical UI
@@ -32,8 +33,6 @@ const CATEGORY_COLORS: Record<string, string> = {
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);
@@ -41,7 +40,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
const fetchKeys = useCallback(async () => {
try {
const res = await fetch("http://localhost:8000/api/settings/api-keys");
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
if (res.ok) {
const data = await res.json();
setApis(data);
@@ -55,35 +54,16 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
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 || "");
setEditValue("");
};
const saveKey = async (api: ApiEntry) => {
if (!api.env_key) return;
setSaving(true);
try {
const res = await fetch("http://localhost:8000/api/settings/api-keys", {
const res = await fetch(`${API_BASE}/api/settings/api-keys`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
@@ -134,22 +114,22 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
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)]"
className="fixed left-0 top-0 bottom-0 w-[480px] bg-[var(--bg-secondary)]/95 backdrop-blur-xl border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.3)]"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-800/80">
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]/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>
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
<span className="text-[9px] text-[var(--text-muted)] 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"
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
@@ -159,7 +139,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
<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">
<p className="text-[10px] text-[var(--text-secondary)] 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>
@@ -172,21 +152,21 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
const isExpanded = expandedCategories.has(category);
return (
<div key={category} className="rounded-lg border border-gray-800/60 overflow-hidden">
<div key={category} className="rounded-lg border border-[var(--border-primary)]/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"
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/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">
<span className="text-[10px] text-[var(--text-muted)] 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" />}
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
</button>
{/* APIs in Category */}
@@ -199,20 +179,26 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
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">
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/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>
<span className="text-xs font-mono text-[var(--text-primary)] 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>
api.is_set ? (
<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-yellow-500/30 text-yellow-400 bg-yellow-950/20">
MISSING
</span>
)
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-gray-700 text-gray-500">
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">
PUBLIC
</span>
)}
@@ -221,7 +207,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
href={api.url}
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-cyan-400 transition-colors"
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={10} />
@@ -231,7 +217,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</div>
{/* Description */}
<p className="text-[10px] text-gray-500 font-mono leading-relaxed mb-2">
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">
{api.description}
</p>
@@ -259,7 +245,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</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"
className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono"
>
ESC
</button>
@@ -268,37 +254,13 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
/* 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"
className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] 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 className="text-[var(--text-muted)] tracking-wider">
{api.is_set ? api.value_obfuscated : "Click to set key..."}
</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>
@@ -314,8 +276,8 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</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">
<div className="p-4 border-t border-[var(--border-primary)]/80">
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
<span>{apis.length} REGISTERED APIs</span>
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
</div>
+2 -2
View File
@@ -49,14 +49,14 @@ export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent =
return (
<div className="pb-2">
{loading && (
<div className={`w-full h-20 rounded bg-gray-800/60 animate-pulse`} />
<div className={`w-full h-20 rounded bg-[var(--bg-tertiary)]/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`}
className={`w-full h-auto ${maxH} object-cover rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`}
/>
</a>
)}
+118 -33
View File
@@ -1,11 +1,39 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect, useRef } 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";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe } from "lucide-react";
import { useTheme } from "@/lib/ThemeContext";
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void }) {
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) {
const [isMinimized, setIsMinimized] = useState(false);
const { theme, toggleTheme } = useTheme();
const [gibsPlaying, setGibsPlaying] = useState(false);
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// GIBS time slider play/pause animation
useEffect(() => {
if (!gibsPlaying || !setGibsDate) {
if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current);
gibsIntervalRef.current = null;
return;
}
gibsIntervalRef.current = setInterval(() => {
if (!gibsDate) return;
const d = new Date(gibsDate + 'T00:00:00');
d.setDate(d.getDate() + 1);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (d > yesterday) {
const start = new Date();
start.setDate(start.getDate() - 30);
setGibsDate(start.toISOString().slice(0, 10));
} else {
setGibsDate(d.toISOString().slice(0, 10));
}
}, 1500);
return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); };
}, [gibsPlaying, gibsDate, setGibsDate]);
// Compute ship category counts
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0;
@@ -27,6 +55,9 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{ id: "global_incidents", name: "Global Incidents", source: "GDELT", count: data?.gdelt?.length || 0, icon: Activity },
{ id: "cctv", name: "CCTV Mesh", source: "CCTV Mesh + Street View", count: data?.cctv?.length || 0, icon: Cctv },
{ id: "gps_jamming", name: "GPS Jamming", source: "ADS-B NACp", count: data?.gps_jamming?.length || 0, icon: Radio },
{ id: "gibs_imagery", name: "MODIS Terra (Daily)", source: "NASA GIBS", count: null, icon: Globe },
{ id: "highres_satellite", name: "High-Res Satellite", source: "Esri World Imagery", count: null, icon: Satellite },
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
];
@@ -41,14 +72,21 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
>
{/* 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="text-[10px] text-[var(--text-secondary)] font-mono tracking-widest mb-1">TOP SECRET // SI-TK // NOFORN</div>
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest mb-4">KH11-4094 OPS-4168</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-[0.2em] text-cyan-50">FLIR</h1>
<h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1>
<button
onClick={toggleTheme}
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]"
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
</button>
{onSettingsClick && (
<button
onClick={onSettingsClick}
className="w-7 h-7 rounded-lg border border-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"
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)] group"
title="System Settings"
>
<Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" />
@@ -57,7 +95,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{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"
className="h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]"
title="Map Legend / Icon Key"
>
<BookOpen size={12} />
@@ -68,15 +106,15 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
</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">
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] flex flex-col relative overflow-hidden max-h-full">
{/* Header / Toggle */}
<div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
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">
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DATA LAYERS</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -95,31 +133,78 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
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 key={idx} className="flex flex-col">
<div
className="flex items-start justify-between group cursor-pointer"
onClick={() => setActiveLayers((prev: any) => ({ ...prev, [layer.id]: !active }))}
>
<div className="flex gap-3">
<div className={`mt-1 ${active ? 'text-cyan-400' : 'text-gray-600 group-hover:text-gray-400'} transition-colors`}>
{(['ships_important', 'ships_civilian', 'ships_passenger'].includes(layer.id)) ? shipIcon : <Icon size={16} strokeWidth={1.5} />}
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span>
</div>
</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 className="flex items-center gap-3">
{active && layer.count > 0 && (
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
)}
<div className={`text-[9px] font-mono tracking-wider px-2 py-0.5 rounded-full border ${active
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/30 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
: 'border-[var(--border-primary)] text-[var(--text-muted)] bg-transparent'
}`}>
{active ? 'ON' : 'OFF'}
</div>
</div>
</div>
<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'}
{/* GIBS Imagery inline controls: time slider + play/pause + opacity */}
{active && layer.id === 'gibs_imagery' && gibsDate && setGibsDate && setGibsOpacity && (
<div className="ml-7 mt-2 flex flex-col gap-2" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-2">
<button
onClick={() => setGibsPlaying(p => !p)}
className="w-5 h-5 flex items-center justify-center rounded border border-cyan-500/30 text-cyan-400 hover:bg-cyan-950/30 transition-colors"
>
{gibsPlaying ? <Pause size={10} /> : <Play size={10} />}
</button>
<input
type="range"
min={0}
max={29}
value={(() => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const selected = new Date(gibsDate + 'T00:00:00');
const diff = Math.round((yesterday.getTime() - selected.getTime()) / 86400000);
return 29 - Math.max(0, Math.min(29, diff));
})()}
onChange={e => {
const daysAgo = 29 - parseInt(e.target.value);
const d = new Date();
d.setDate(d.getDate() - 1 - daysAgo);
setGibsDate(d.toISOString().slice(0, 10));
}}
className="flex-1 h-1 accent-cyan-500 cursor-pointer"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[8px] text-cyan-400 font-mono">{gibsDate}</span>
<div className="flex items-center gap-1">
<span className="text-[8px] text-[var(--text-muted)] font-mono">OPC</span>
<input
type="range"
min={0}
max={100}
value={Math.round((gibsOpacity ?? 0.6) * 100)}
onChange={e => setGibsOpacity(parseInt(e.target.value) / 100)}
className="w-16 h-1 accent-cyan-500 cursor-pointer"
/>
</div>
</div>
</div>
</div>
)}
</div>
)
})}
+17 -17
View File
@@ -26,14 +26,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
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]'}`}
className={`w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl z-10 flex flex-col font-mono shadow-[0_4px_30px_rgba(0,0,0,0.2)] 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="flex items-center gap-3 mb-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]/40 backdrop-blur-md px-4 py-2 rounded-sm relative shadow-[0_4px_30px_rgba(0,0,0,0.2)] pointer-events-auto">
<div className="absolute -top-1 -left-1 w-2 h-2 border-t border-l border-[var(--text-muted)]/50"></div>
<div className="absolute -bottom-1 -right-1 w-2 h-2 border-b border-r border-[var(--text-muted)]/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">
<div className="text-[10px] font-mono text-[var(--text-secondary)] tracking-wider">
REC {currentTime.date} {currentTime.time}
<br />
ORB: 47696 PASS: DESC-284
@@ -41,15 +41,15 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
</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">
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
{/* 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"
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
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">
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DISPLAY CONFIG</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
@@ -66,14 +66,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
{/* 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'}`}
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-[var(--border-primary)]'}`}
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>
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'}`}>BLOOM</span>
</div>
<span className="text-[9px] font-mono tracking-wider text-gray-500">{effects.bloom ? 'ON' : 'OFF'}</span>
<span className="text-[9px] font-mono tracking-wider text-[var(--text-muted)]">{effects.bloom ? 'ON' : 'OFF'}</span>
</div>
{/* Sharpen Slider */}
@@ -86,7 +86,7 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
<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="h-0.5 bg-[var(--border-primary)] 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>
@@ -96,14 +96,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
{/* HUD Dropdown */}
<div className="flex flex-col gap-2 relative">
<div className="flex items-center gap-3 border border-gray-800 rounded px-4 py-3 text-gray-500 cursor-default">
<div className="flex items-center gap-3 border border-[var(--border-primary)] rounded px-4 py-3 text-[var(--text-muted)] cursor-default">
<span className="w-3 h-3 border border-gray-500 rounded-full flex items-center justify-center"></span>
<span className="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">
<div className="flex items-center justify-between border border-[var(--border-primary)] rounded px-4 py-2 mt-1 bg-[var(--bg-primary)]/50">
<span className="text-[10px] text-[var(--text-muted)] font-mono">LAYOUT</span>
<span className="text-xs text-[var(--text-primary)] tracking-widest border-b border-dashed border-[var(--border-secondary)] pb-0.5 cursor-pointer flex items-center gap-2">
Tactical
</span>
</div>
+39
View File
@@ -0,0 +1,39 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
type Theme = "dark" | "light";
const ThemeContext = createContext<{ theme: Theme; toggleTheme: () => void }>({
theme: "dark",
toggleTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
useEffect(() => {
const saved = localStorage.getItem("sb-theme") as Theme | null;
if (saved === "light" || saved === "dark") {
setTheme(saved);
document.documentElement.setAttribute("data-theme", saved);
}
}, []);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("sb-theme", next);
document.documentElement.setAttribute("data-theme", next);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
+28
View File
@@ -0,0 +1,28 @@
// NEXT_PUBLIC_* vars are baked at build time in Next.js, so setting them
// in docker-compose `environment` has no effect at runtime. Instead we
// auto-detect: use the browser's current hostname with a configurable port
// so the dashboard works on localhost, LAN IPs, and custom Docker port maps
// without any code changes.
//
// Override order:
// 1. Build-time NEXT_PUBLIC_API_URL (for advanced users who rebuild the image)
// 2. Runtime auto-detect from window.location.hostname + port 8000
function resolveApiBase(): string {
// Build-time override (works when image is rebuilt with the env var)
if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL;
}
// Server-side rendering: fall back to localhost
if (typeof window === "undefined") {
return "http://localhost:8000";
}
// Client-side: use the same hostname the user is browsing on
const proto = window.location.protocol;
const host = window.location.hostname;
return `${proto}//${host}:8000`;
}
export const API_BASE = resolveApiBase();
+7
View File
@@ -0,0 +1,7 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
};
export default config;
-105
View File
@@ -1,105 +0,0 @@
import re
with open('frontend/src/components/CesiumViewer.tsx', 'r', encoding='utf-8') as f:
code = f.read()
# Replace removeAll with diffing setup
setup_code = """ // Handle Entities gracefully to prevent stutter
viewer.entities.suspendEvents();
const touchedIds = new Set<string>();
const addOrUpdate = (props: any) => {
if (!props.id) props.id = "gen-" + Math.random().toString(36).substr(2, 9);
touchedIds.add(props.id);
const existing = viewer.entities.getById(props.id);
if (existing) {
if (props.position) existing.position = props.position;
if (props.label && existing.label) existing.label.text = props.label.text;
if (props.billboard && existing.billboard) {
existing.billboard.rotation = props.billboard.rotation;
existing.billboard.image = props.billboard.image;
}
if (props.polyline && existing.polyline) existing.polyline.positions = props.polyline.positions;
} else {
viewer.entities.add(props);
}
};"""
code = code.replace(" viewer.entities.removeAll();", setup_code)
# Replace all viewer.entities.add({ with addOrUpdate({
code = code.replace("viewer.entities.add({", "addOrUpdate({")
# Add missing IDs for flight origin/dest/polyline dynamically
code = re.sub(
r"addOrUpdate\(\{\s*position: Cesium\.Cartesian3\.fromDegrees\(flight\.origin_loc",
r"addOrUpdate({\n id: `sel-origin-${selectedEntity.entityId}`,\n position: Cesium.Cartesian3.fromDegrees(flight.origin_loc",
code
)
code = re.sub(
r"addOrUpdate\(\{\s*position: Cesium\.Cartesian3\.fromDegrees\(flight\.dest_loc",
r"addOrUpdate({\n id: `sel-dest-${selectedEntity.entityId}`,\n position: Cesium.Cartesian3.fromDegrees(flight.dest_loc",
code
)
code = re.sub(
r"addOrUpdate\(\{\s*polyline: \{\s*positions: Cesium\.Cartesian3\.fromDegreesArrayHeights\(\[",
r"addOrUpdate({\n id: `sel-poly-${selectedEntity.entityId}`,\n polyline: {\n positions: Cesium.Cartesian3.fromDegreesArrayHeights([",
code
)
# Weather layer pruning
code = code.replace(
""" // Process Weather Radar
if (data.weather && activeLayers?.weather !== false) {
let weatherLayer = viewer.imageryLayers._layers.find((l: any) => l.imageryProvider.url && l.imageryProvider.url.includes("rainviewer"));
if (!weatherLayer) {
viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
url: `${data.weather.host} / v2 / radar / ${data.weather.time} / 256 / { z } / { x } / { y } / 2 / 1_1.png`,
credit: ""
}), 1); // Add just above base map
}
} else {
const weatherLayer = viewer.imageryLayers._layers.find((l: any) => l.imageryProvider.url && l.imageryProvider.url.includes("rainviewer"));
if (weatherLayer) viewer.imageryLayers.remove(weatherLayer);
}""",
""" // Process Weather Radar
if (data.weather && activeLayers?.weather !== false) {
const targetUrl = `${data.weather.host}/v2/radar/${data.weather.time}/256/{z}/{x}/{y}/2/1_1.png`;
let weatherLayer = viewer.imageryLayers._layers.find((l: any) => l.imageryProvider.url && l.imageryProvider.url.includes("rainviewer"));
if (weatherLayer && weatherLayer.imageryProvider.url !== targetUrl) {
viewer.imageryLayers.remove(weatherLayer);
weatherLayer = null;
}
if (!weatherLayer) {
viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
url: targetUrl,
credit: ""
}), 1);
}
} else {
const weatherLayer = viewer.imageryLayers._layers.find((l: any) => l.imageryProvider.url && l.imageryProvider.url.includes("rainviewer"));
if (weatherLayer) viewer.imageryLayers.remove(weatherLayer);
}"""
)
# Insert resume events
code = code.replace(
" }, [data, activeLayers, effects, selectedEntity]);",
"""
// Prune unused entities
const allEntities = viewer.entities.values;
for (let i = allEntities.length - 1; i >= 0; i--) {
const e = allEntities[i];
if (!touchedIds.has(e.id)) {
viewer.entities.remove(e);
}
}
viewer.entities.resumeEvents();
}, [data, activeLayers, effects, selectedEntity]);"""
)
with open('frontend/src/components/CesiumViewer.tsx', 'w', encoding='utf-8') as f:
f.write(code)
print("CesiumViewer refactored.")
+20
View File
@@ -0,0 +1,20 @@
const { execSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const backendDir = path.resolve(__dirname, "backend");
const venvBin = process.platform === "win32"
? path.join(backendDir, "venv", "Scripts", "python.exe")
: path.join(backendDir, "venv", "bin", "python3");
if (!fs.existsSync(venvBin)) {
console.error(`[!] Python venv not found at: ${venvBin}`);
console.error("[!] Run start.sh (Mac/Linux) or start.bat (Windows) first to create the venv.");
process.exit(1);
}
console.log(`[*] Starting backend with: ${venvBin}`);
execSync(`"${venvBin}" -m uvicorn main:app --reload`, {
cwd: backendDir,
stdio: "inherit",
});
+62 -8
View File
@@ -5,28 +5,82 @@ echo ===================================================
echo S H A D O W B R O K E R -- STARTUP
echo ===================================================
echo.
echo Installing backend dependencies if needed...
:: Check for Python
where python >nul 2>&1
if %errorlevel% neq 0 (
echo [!] ERROR: Python is not installed or not in PATH.
echo [!] Install Python 3.10-3.12 from https://python.org
echo [!] IMPORTANT: Check "Add to PATH" during install.
echo.
pause
exit /b 1
)
:: Check Python version
for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYVER=%%v
echo [*] Found Python %PYVER%
:: Check for Node.js
where npm >nul 2>&1
if %errorlevel% neq 0 (
echo [!] ERROR: Node.js/npm is not installed or not in PATH.
echo [!] Install Node.js 18+ from https://nodejs.org
echo.
pause
exit /b 1
)
for /f "tokens=1 delims= " %%v in ('node --version 2^>^&1') do echo [*] Found Node.js %%v
echo.
echo [*] Setting up backend...
cd backend
if not exist "venv\" (
echo Creating Python virtual environment...
echo [*] Creating Python virtual environment...
python -m venv venv
if %errorlevel% neq 0 (
echo [!] ERROR: Failed to create virtual environment.
pause
exit /b 1
)
)
call venv\Scripts\activate.bat
pip install -r requirements.txt >nul 2>&1
echo [*] Installing Python dependencies (this may take a minute)...
pip install -r requirements.txt
if %errorlevel% neq 0 (
echo.
echo [!] ERROR: pip install failed. See errors above.
echo [!] If you see Rust/cargo errors, your Python version may be too new.
echo [!] Recommended: Python 3.10, 3.11, or 3.12.
echo.
pause
exit /b 1
)
echo [*] Backend dependencies OK.
cd ..
echo.
echo Installing frontend dependencies if needed...
echo [*] Setting up frontend...
cd frontend
if not exist "node_modules\" (
echo Running npm install...
echo [*] Installing frontend dependencies...
call npm install
if %errorlevel% neq 0 (
echo [!] ERROR: npm install failed. See errors above.
pause
exit /b 1
)
)
echo [*] Frontend dependencies OK.
echo.
echo Starting both services...
echo (Press Ctrl+C to stop the dashboard)
echo ===================================================
echo Starting services...
echo Dashboard: http://localhost:3000
echo Keep this window open! Initial load takes ~10s.
echo ===================================================
echo (Press Ctrl+C to stop)
echo.
:: Start the dev server which runs both NEXT and API via concurrently
call npm run dev
+58 -30
View File
@@ -6,47 +6,75 @@ echo ""
# Check for Node.js
if ! command -v npm &> /dev/null; then
echo "[!] ERROR: npm is not installed. Please install Node.js (https://nodejs.org/)"
echo "[!] ERROR: npm is not installed. Please install Node.js 18+ (https://nodejs.org/)"
exit 1
fi
echo "[*] Found Node.js $(node --version)"
# Check for Python
if ! command -v python3 &> /dev/null; then
echo "[!] ERROR: python3 is not installed. Please install Python 3.10+ (https://python.org/)"
exit 1
fi
echo "[*] Setting up Backend Environment..."
cd backend
if [ ! -d "venv" ]; then
echo "[*] Creating Python Virtual Environment..."
python3 -m venv venv
fi
echo "[*] Installing Backend dependencies..."
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then
# In case someone runs this in Git Bash on Windows
source venv/Scripts/activate
# Check for Python 3
PYTHON_CMD=""
if command -v python3 &> /dev/null; then
PYTHON_CMD="python3"
elif command -v python &> /dev/null; then
PYTHON_CMD="python"
else
source venv/bin/activate
echo "[!] ERROR: Python is not installed."
echo "[!] Install Python 3.10-3.12 from https://python.org"
exit 1
fi
echo "[*] Found $($PYTHON_CMD --version 2>&1)"
# Get the directory where this script lives
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo ""
echo "[*] Setting up backend..."
cd "$SCRIPT_DIR/backend"
if [ ! -d "venv" ]; then
echo "[*] Creating Python virtual environment..."
$PYTHON_CMD -m venv venv
if [ $? -ne 0 ]; then
echo "[!] ERROR: Failed to create virtual environment."
exit 1
fi
fi
source venv/bin/activate
echo "[*] Installing Python dependencies (this may take a minute)..."
pip install -r requirements.txt
cd ..
echo "[*] Setting up Frontend Environment..."
cd frontend
if [ ! -d "node_modules" ]; then
echo "[*] Installing Frontend dependencies..."
npm install
if [ $? -ne 0 ]; then
echo ""
echo "[!] ERROR: pip install failed. See errors above."
echo "[!] If you see Rust/cargo errors, your Python version may be too new."
echo "[!] Recommended: Python 3.10, 3.11, or 3.12."
exit 1
fi
echo "[*] Backend dependencies OK."
deactivate
cd "$SCRIPT_DIR"
echo ""
echo "[*] Setting up frontend..."
cd "$SCRIPT_DIR/frontend"
if [ ! -d "node_modules" ]; then
echo "[*] Installing frontend dependencies..."
npm install
if [ $? -ne 0 ]; then
echo "[!] ERROR: npm install failed. See errors above."
exit 1
fi
fi
echo "[*] Frontend dependencies OK."
echo ""
echo "======================================================="
echo " 🚀 Starting Services... "
echo " Dashboard will be available at: http://localhost:3000"
echo " Keep this window open! Note: Initial load takes ~10s "
echo " Starting services... "
echo " Dashboard: http://localhost:3000 "
echo " Keep this window open! Initial load takes ~10s. "
echo "======================================================="
echo " (Press Ctrl+C to stop)"
echo ""
# Start both services (npm run dev automatically calls the python backend on Mac/Linux if scripts are configured cross-platform)
npm run dev
-21
View File
@@ -1,21 +0,0 @@
import os
import zipfile
def create_clean_zip():
zip_name = 'ShadowBroker_v0.1.zip'
exclude_dirs = {'.git', 'node_modules', 'venv', '.next', '__pycache__'}
exclude_files = {zip_name, 'zip_repo.py', '.env', '.env.local'}
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in exclude_dirs]
for file in files:
if file in exclude_files:
continue
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, '.')
zipf.write(file_path, arcname)
print(f"Created {zip_name} successfully!")
if __name__ == '__main__':
create_clean_zip()