mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-14 20:38:45 +02:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97208a01a2 | |||
| d4c725de6e | |||
| d756dd5bd3 | |||
| d96e8f5c21 | |||
| 8afcbca667 | |||
| b68de6a594 | |||
| 36dec1088d | |||
| a38f4cbaea | |||
| 8e7ef8e95e | |||
| e597147a16 | |||
| 71c085cdd5 | |||
| c9cec26309 | |||
| 03aae3216b | |||
| 31755b294e | |||
| 9c831e37ff | |||
| 686e304358 | |||
| 8cddf6794d | |||
| a98f46c708 | |||
| d6f97df336 | |||
| 91a63cf17a | |||
| 354ed37e1a | |||
| 3c18bef174 | |||
| 09c2d3d810 | |||
| 2e53d6d7af | |||
| bf0da2c434 | |||
| a57c9be0cb | |||
| e82a5ae3be | |||
| 3326c520a9 | |||
| 24e4d331fc | |||
| c96f6ad723 | |||
| 923c80368d | |||
| 30595843a0 | |||
| cef06ff809 | |||
| 502359fc30 | |||
| 19a0ef1c70 | |||
| 197d37ae5a | |||
| 0c9d047509 | |||
| 2147eee0a6 | |||
| 1298dd326b | |||
| ed5bc5a23b | |||
| fbd64b6038 | |||
| 8d4403c7e6 | |||
| 5e3eae0f00 | |||
| 9d58be6bbb | |||
| 45e6258ea4 | |||
| c1f89ae446 | |||
| ff19d2bc68 | |||
| 9c85e08839 | |||
| c8f3812fbf | |||
| ffb3041a2b | |||
| 775bc4adfe | |||
| f9a8a998c4 | |||
| 8c843393d1 | |||
| 27213cb74a | |||
| e3237dfba3 | |||
| fa9ce48782 | |||
| d36061976e |
@@ -0,0 +1,100 @@
|
||||
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
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- 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
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- 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
@@ -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
|
||||
|
||||
@@ -2,114 +2,163 @@
|
||||
<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.
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
**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 and see high-resolution satellite imagery
|
||||
* Nose around local emergency scanners
|
||||
* 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 +170,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 +181,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 +254,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 +271,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 +292,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 +317,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 +328,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 +355,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 +375,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 +387,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 +0,0 @@
|
||||
ba57965389036194d6dd60e6de33d2e1e1bbf20b
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 []
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)}")
|
||||
@@ -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]}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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])
|
||||
@@ -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())}")
|
||||
@@ -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
|
||||
@@ -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', []))}")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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')}")
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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]}")
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Generated
+74
-165
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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 & 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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='© <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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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)}%
|
||||
|
||||
@@ -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">>_ 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">
|
||||
>_ {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>>_ {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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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.")
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user