mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-15 04:40:26 +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
|
merged.txt
|
||||||
tmp_fast.json
|
tmp_fast.json
|
||||||
TheAirTraffic Database.xlsx
|
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>
|
<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"><strong>Global Threat Intercept — Real-Time Geospatial Intelligence Platform</strong></p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<code>TOP SECRET // SI TK // NOFORN</code>
|
|
||||||
</p>
|
</p>
|
||||||
</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.
|
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
|
## ✨ Features
|
||||||
|
|
||||||
### 🛩️ Aviation Tracking
|
### 🛩️ Aviation Tracking
|
||||||
|
|
||||||
- **Commercial Flights** — Real-time positions via OpenSky Network (~5,000+ aircraft)
|
* **Commercial Flights** — Real-time positions via OpenSky Network (~5,000+ aircraft)
|
||||||
- **Private Aircraft** — Light GA, turboprops, bizjets tracked separately
|
* **Private Aircraft** — Light GA, turboprops, bizjets tracked separately
|
||||||
- **Private Jets** — High-net-worth individual aircraft with owner identification
|
* **Private Jets** — High-net-worth individual aircraft with owner identification
|
||||||
- **Military Flights** — Tankers, ISR, fighters, transports via adsb.lol military endpoint
|
* **Military Flights** — Tankers, ISR, fighters, transports via adsb.lol military endpoint
|
||||||
- **Flight Trail Accumulation** — Persistent breadcrumb trails for all tracked aircraft
|
* **Flight Trail Accumulation** — Persistent breadcrumb trails for all tracked aircraft
|
||||||
- **Holding Pattern Detection** — Automatically flags aircraft circling (>300° total turn)
|
* **Holding Pattern Detection** — Automatically flags aircraft circling (>300° total turn)
|
||||||
- **Aircraft Classification** — Shape-accurate SVG icons: airliners, turboprops, bizjets, helicopters
|
* **Aircraft Classification** — Shape-accurate SVG icons: airliners, turboprops, bizjets, helicopters
|
||||||
- **Grounded Detection** — Aircraft below 100ft AGL rendered with grey icons
|
* **Grounded Detection** — Aircraft below 100ft AGL rendered with grey icons
|
||||||
|
|
||||||
### 🚢 Maritime Tracking
|
### 🚢 Maritime Tracking
|
||||||
|
|
||||||
- **AIS Vessel Stream** — 25,000+ vessels via aisstream.io WebSocket (real-time)
|
* **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
|
* **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
|
* **Carrier Strike Group Tracker** — All 11 active US Navy aircraft carriers with OSINT-estimated positions
|
||||||
- Automated GDELT news scraping for carrier movement intelligence
|
* Automated GDELT news scraping for carrier movement intelligence
|
||||||
- 50+ geographic region-to-coordinate mappings
|
* 50+ geographic region-to-coordinate mappings
|
||||||
- Disk-cached positions, auto-updates at 00:00 & 12:00 UTC
|
* Disk-cached positions, auto-updates at 00:00 & 12:00 UTC
|
||||||
- **Cruise & Passenger Ships** — Dedicated layer for cruise liners and ferries
|
* **Cruise & Passenger Ships** — Dedicated layer for cruise liners and ferries
|
||||||
- **Clustered Display** — Ships cluster at low zoom with count labels, decluster on zoom-in
|
* **Clustered Display** — Ships cluster at low zoom with count labels, decluster on zoom-in
|
||||||
|
|
||||||
### 🛰️ Space & Satellites
|
### 🛰️ Space & Satellites
|
||||||
|
|
||||||
- **Orbital Tracking** — Real-time satellite positions from N2YO API
|
* **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)
|
* **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
|
### 🌍 Geopolitics & Conflict
|
||||||
|
|
||||||
- **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
|
* **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
|
||||||
- **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
|
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
|
||||||
- **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources
|
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources
|
||||||
- **Region Dossier** — Right-click anywhere on the map for:
|
* **Region Dossier** — Right-click anywhere on the map for:
|
||||||
- Country profile (population, capital, languages, currencies, area)
|
* Country profile (population, capital, languages, currencies, area)
|
||||||
- Head of state & government type (Wikidata SPARQL)
|
* Head of state & government type (Wikidata SPARQL)
|
||||||
- Local Wikipedia summary with thumbnail
|
* 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
|
### 📷 Surveillance
|
||||||
|
|
||||||
- **CCTV Mesh** — 2,000+ live traffic cameras from:
|
* **CCTV Mesh** — 2,000+ live traffic cameras from:
|
||||||
- 🇬🇧 Transport for London JamCams
|
* 🇬🇧 Transport for London JamCams
|
||||||
- 🇺🇸 Austin, TX TxDOT
|
* 🇺🇸 Austin, TX TxDOT
|
||||||
- 🇺🇸 NYC DOT
|
* 🇺🇸 NYC DOT
|
||||||
- 🇸🇬 Singapore LTA
|
* 🇸🇬 Singapore LTA
|
||||||
- Custom URL ingestion
|
* Custom URL ingestion
|
||||||
- **Feed Rendering** — Automatic detection & rendering of video, MJPEG, HLS, embed, satellite tile, and image feeds
|
* **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
|
* **Clustered Map Display** — Green dots cluster with count labels, decluster on zoom
|
||||||
|
|
||||||
### 📡 Signal Intelligence
|
### 📡 Signal Intelligence
|
||||||
|
|
||||||
- **GPS Jamming Detection** — Real-time analysis of aircraft NAC-P (Navigation Accuracy Category) values
|
* **GPS Jamming Detection** — Real-time analysis of aircraft NAC-P (Navigation Accuracy Category) values
|
||||||
- Grid-based aggregation identifies interference zones
|
* Grid-based aggregation identifies interference zones
|
||||||
- Red overlay squares with "GPS JAM XX%" severity labels
|
* Red overlay squares with "GPS JAM XX%" severity labels
|
||||||
- **Radio Intercept Panel** — Scanner-style UI for monitoring communications
|
* **Radio Intercept Panel** — Scanner-style UI for monitoring communications
|
||||||
|
|
||||||
### 🌐 Additional Layers
|
### 🌐 Additional Layers
|
||||||
|
|
||||||
- **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
|
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
|
||||||
- **Day/Night Cycle** — Solar terminator overlay showing global daylight/darkness
|
* **Day/Night Cycle** — Solar terminator overlay showing global daylight/darkness
|
||||||
- **Global Markets Ticker** — Live financial market indices (minimizable)
|
* **Global Markets Ticker** — Live financial market indices (minimizable)
|
||||||
- **Measurement Tool** — Point-to-point distance & bearing measurement on the map
|
* **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
|
## 🏗️ Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────┐
|
||||||
│ FRONTEND (Next.js) │
|
│ FRONTEND (Next.js) │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────┐ ┌──────────┐ ┌─────────────────┐ │
|
│ ┌─────────────┐ ┌──────────┐ ┌───────────────┐ │
|
||||||
│ │ MapLibre GL │ │ NewsFeed │ │ Control Panels │ │
|
│ │ MapLibre GL │ │ NewsFeed │ │ Control Panels│ │
|
||||||
│ │ 2D WebGL │ │ SIGINT │ │ Layers/Filters │ │
|
│ │ 2D WebGL │ │ SIGINT │ │ Layers/Filters│ │
|
||||||
│ │ Map Render │ │ Intel │ │ Markets/Radio │ │
|
│ │ Map Render │ │ Intel │ │ Markets/Radio │ │
|
||||||
│ └──────┬──────┘ └────┬─────┘ └────────┬────────┘ │
|
│ └──────┬──────┘ └────┬─────┘ └───────┬───────┘ │
|
||||||
│ └──────────────┼─────────────────┘ │
|
│ └────────────────┼──────────────────┘ │
|
||||||
│ │ REST API (15s fast / 60s slow│
|
│ │ REST API (60s / 120s) │
|
||||||
├────────────────────────┼─────────────────────────────┤
|
├──────────────────────────┼─────────────────────────────┤
|
||||||
│ BACKEND (FastAPI) │
|
│ BACKEND (FastAPI) │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌─────────────────────┼─────────────────────────┐ │
|
│ ┌───────────────────────┼──────────────────────────┐ │
|
||||||
│ │ Data Fetcher (Scheduler) │ │
|
│ │ Data Fetcher (Scheduler) │ │
|
||||||
│ │ ┌──────────┬──────────┬──────────┬─────────┐ │ │
|
│ │ │ │
|
||||||
│ │ │ OpenSky │ adsb.lol │ N2YO │ USGS │ │ │
|
│ │ ┌──────────┬──────────┬──────────┬───────────┐ │ │
|
||||||
│ │ │ Flights │ Military │ Sats │ Quakes │ │ │
|
│ │ │ OpenSky │ adsb.lol │CelesTrak │ USGS │ │ │
|
||||||
│ │ ├──────────┼──────────┼──────────┼─────────┤ │ │
|
│ │ │ Flights │ Military │ Sats │ Quakes │ │ │
|
||||||
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV │ │ │
|
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||||
│ │ │ Ships │ Tracker │ Conflict │ Cameras │ │ │
|
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV │ │ │
|
||||||
│ │ ├──────────┼──────────┼──────────┼─────────┤ │ │
|
│ │ │ Ships │ Tracker │ Conflict │ Cameras │ │ │
|
||||||
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||||
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
│ │ │ 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) |
|
| [OpenSky Network](https://opensky-network.org) | Commercial & private flights | ~60s | Optional (anonymous limited) |
|
||||||
| [adsb.lol](https://adsb.lol) | Military aircraft | ~60s | No |
|
| [adsb.lol](https://adsb.lol) | Military aircraft | ~60s | No |
|
||||||
| [aisstream.io](https://aisstream.io) | AIS vessel positions | Real-time WebSocket | **Yes** |
|
| [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 |
|
| [USGS Earthquake](https://earthquake.usgs.gov) | Global seismic events | ~60s | No |
|
||||||
| [GDELT Project](https://www.gdeltproject.org) | Global conflict events | ~6h | No |
|
| [GDELT Project](https://www.gdeltproject.org) | Global conflict events | ~6h | No |
|
||||||
| [DeepState Map](https://deepstatemap.live) | Ukraine frontline | ~30min | 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 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)
|
### 📦 Quick Start (No Code Required)
|
||||||
|
|
||||||
If you just want to run the dashboard without dealing with terminal commands:
|
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.
|
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.
|
3. Extract the folder to your computer.
|
||||||
4. **Windows:** Double-click `start.bat`.
|
4. **Windows:** Double-click `start.bat`.
|
||||||
**Mac/Linux:** Open terminal, type `chmod +x start.sh`, and run `./start.sh`.
|
**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
|
#### Prerequisites
|
||||||
|
|
||||||
- **Node.js** 18+ and **npm**
|
* **Node.js** 18+ and **npm** — [nodejs.org](https://nodejs.org/)
|
||||||
- **Python** 3.10+ with `pip`
|
* **Python** 3.10, 3.11, or 3.12 with `pip` — [python.org](https://www.python.org/downloads/) (**check "Add to PATH"** during install)
|
||||||
- API keys for: `aisstream.io`, `n2yo.com` (and optionally `opensky-network.org`, `lta.gov.sg`)
|
* ⚠️ 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
|
### Installation
|
||||||
|
|
||||||
@@ -173,13 +271,12 @@ cd backend
|
|||||||
python -m venv venv
|
python -m venv venv
|
||||||
venv\Scripts\activate # Windows
|
venv\Scripts\activate # Windows
|
||||||
# source venv/bin/activate # macOS/Linux
|
# 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
|
# Create .env with your API keys
|
||||||
echo "AISSTREAM_API_KEY=your_key_here" >> .env
|
echo "AIS_API_KEY=your_aisstream_key" >> .env
|
||||||
echo "N2YO_API_KEY=your_key_here" >> .env
|
echo "OPENSKY_CLIENT_ID=your_opensky_client_id" >> .env
|
||||||
echo "OPENSKY_USERNAME=your_user" >> .env
|
echo "OPENSKY_CLIENT_SECRET=your_opensky_secret" >> .env
|
||||||
echo "OPENSKY_PASSWORD=your_pass" >> .env
|
|
||||||
|
|
||||||
# Frontend setup
|
# Frontend setup
|
||||||
cd ../frontend
|
cd ../frontend
|
||||||
@@ -195,8 +292,8 @@ npm run dev
|
|||||||
|
|
||||||
This starts:
|
This starts:
|
||||||
|
|
||||||
- **Next.js** frontend on `http://localhost:3000`
|
* **Next.js** frontend on `http://localhost:3000`
|
||||||
- **FastAPI** backend on `http://localhost:8000`
|
* **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 |
|
| Ukraine Frontline | ✅ ON | Live warfront positions |
|
||||||
| Global Incidents | ✅ ON | GDELT conflict events |
|
| Global Incidents | ✅ ON | GDELT conflict events |
|
||||||
| GPS Jamming | ✅ ON | NAC-P degradation zones |
|
| 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 |
|
| 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:
|
The platform is optimized for handling massive real-time datasets:
|
||||||
|
|
||||||
- **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
|
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
|
||||||
- **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
|
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
|
||||||
- **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
|
* **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
|
* **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
|
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom
|
||||||
- **Position Interpolation** — Smooth 10s tick animation between data refreshes
|
* **Position Interpolation** — Smooth 10s tick animation between data refreshes
|
||||||
- **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
|
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
|
||||||
- **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
|
* **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
|
│ ├── geopolitics.py # GDELT + Ukraine frontline fetcher
|
||||||
│ ├── region_dossier.py # Right-click country/city intelligence
|
│ ├── region_dossier.py # Right-click country/city intelligence
|
||||||
│ ├── radio_intercept.py # Scanner radio feed integration
|
│ ├── 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
|
│ ├── network_utils.py # HTTP client with curl fallback
|
||||||
│ └── api_settings.py # API key management
|
│ └── api_settings.py # API key management
|
||||||
│
|
│
|
||||||
@@ -273,6 +375,7 @@ live-risk-dashboard/
|
|||||||
│ │ ├── MarketsPanel.tsx # Global financial markets ticker
|
│ │ ├── MarketsPanel.tsx # Global financial markets ticker
|
||||||
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
|
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
|
||||||
│ │ ├── FindLocateBar.tsx # Search/locate bar
|
│ │ ├── FindLocateBar.tsx # Search/locate bar
|
||||||
|
│ │ ├── ChangelogModal.tsx # Version changelog popup
|
||||||
│ │ ├── SettingsPanel.tsx # App settings
|
│ │ ├── SettingsPanel.tsx # App settings
|
||||||
│ │ ├── ScaleBar.tsx # Map scale indicator
|
│ │ ├── ScaleBar.tsx # Map scale indicator
|
||||||
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
||||||
@@ -284,19 +387,29 @@ live-risk-dashboard/
|
|||||||
|
|
||||||
## 🔑 Environment Variables
|
## 🔑 Environment Variables
|
||||||
|
|
||||||
Create a `.env` file in the `backend/` directory:
|
### Backend (`backend/.env`)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Required
|
# Required
|
||||||
AISSTREAM_API_KEY=your_aisstream_key # Maritime vessel tracking
|
AIS_API_KEY=your_aisstream_key # Maritime vessel tracking (aisstream.io)
|
||||||
N2YO_API_KEY=your_n2yo_key # Satellite position data
|
|
||||||
|
|
||||||
# Optional (enhances data quality)
|
# Optional (enhances data quality)
|
||||||
OPENSKY_CLIENT_ID=your_opensky_client_id # Higher rate limits for flight data
|
OPENSKY_CLIENT_ID=your_opensky_client_id # OAuth2 — higher rate limits for flight data
|
||||||
OPENSKY_CLIENT_SECRET=your_opensky_secret
|
OPENSKY_CLIENT_SECRET=your_opensky_secret # OAuth2 — paired with Client ID above
|
||||||
LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras
|
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
|
## ⚠️ 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
|
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
|
# Install dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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 source code
|
||||||
COPY . .
|
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 port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
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 = [
|
const FILTER = [
|
||||||
// US Aircraft Carriers and major naval groups
|
// 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(GZipMiddleware, minimum_size=1000)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
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_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -59,6 +59,7 @@ async def live_data_fast(request: Request):
|
|||||||
"private_jets": d.get("private_jets", []),
|
"private_jets": d.get("private_jets", []),
|
||||||
"tracked_flights": d.get("tracked_flights", []),
|
"tracked_flights": d.get("tracked_flights", []),
|
||||||
"ships": d.get("ships", []),
|
"ships": d.get("ships", []),
|
||||||
|
"satellites": d.get("satellites", []),
|
||||||
"cctv": d.get("cctv", []),
|
"cctv": d.get("cctv", []),
|
||||||
"uavs": d.get("uavs", []),
|
"uavs": d.get("uavs", []),
|
||||||
"liveuamap": d.get("liveuamap", []),
|
"liveuamap": d.get("liveuamap", []),
|
||||||
@@ -91,7 +92,8 @@ async def live_data_slow(request: Request):
|
|||||||
"frontlines": d.get("frontlines"),
|
"frontlines": d.get("frontlines"),
|
||||||
"gdelt": d.get("gdelt", []),
|
"gdelt": d.get("gdelt", []),
|
||||||
"airports": d.get("airports", []),
|
"airports": d.get("airports", []),
|
||||||
"satellites": d.get("satellites", [])
|
"satellites": d.get("satellites", []),
|
||||||
|
"kiwisdr": d.get("kiwisdr", [])
|
||||||
}
|
}
|
||||||
# ETag based on last_updated + item counts
|
# ETag based on last_updated + item counts
|
||||||
last_updated = d.get("last_updated", "")
|
last_updated = d.get("last_updated", "")
|
||||||
@@ -112,7 +114,25 @@ async def debug_latest_data():
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check():
|
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
|
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."""
|
"""Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop."""
|
||||||
return get_region_dossier(lat, lng)
|
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
|
# API Settings — key registry & management
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
fastapi==0.103.1
|
fastapi>=0.103.1
|
||||||
uvicorn==0.23.2
|
uvicorn>=0.23.2
|
||||||
yfinance>=0.2.40
|
yfinance>=0.2.40
|
||||||
feedparser==6.0.10
|
feedparser==6.0.10
|
||||||
|
legacy-cgi>=2.6
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
apscheduler==3.10.3
|
apscheduler==3.10.3
|
||||||
pydantic==2.3.0
|
pydantic>=2.3.0
|
||||||
pydantic-settings==2.0.3
|
pydantic-settings>=2.0.3
|
||||||
playwright>=1.58.0
|
playwright>=1.58.0
|
||||||
beautifulsoup4>=4.12.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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
AIS_WS_URL = "wss://stream.aisstream.io/v0/stream"
|
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
|
# AIS vessel type code classification
|
||||||
# See: https://coast.noaa.gov/data/marinecadastre/ais/VesselTypeCodes2018.pdf
|
# 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."""
|
"""Main loop: spawn node proxy and process messages from stdout."""
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
|
|
||||||
proxy_script = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ais_proxy.js")
|
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:
|
while _ws_running:
|
||||||
try:
|
try:
|
||||||
logger.info("Starting Node.js AIS Stream Proxy...")
|
logger.info("Starting Node.js AIS Stream Proxy...")
|
||||||
@@ -323,8 +324,12 @@ def _ais_stream_loop():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AIS proxy connection error: {e}")
|
logger.error(f"AIS proxy connection error: {e}")
|
||||||
if _ws_running:
|
if _ws_running:
|
||||||
logger.info("Restarting AIS proxy in 5 seconds...")
|
logger.info(f"Restarting AIS proxy in {backoff}s (exponential backoff)...")
|
||||||
time.sleep(5)
|
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():
|
def _run_ais_loop():
|
||||||
|
|||||||
@@ -145,20 +145,29 @@ def get_api_keys():
|
|||||||
"has_key": api["env_key"] is not None,
|
"has_key": api["env_key"] is not None,
|
||||||
"env_key": api["env_key"],
|
"env_key": api["env_key"],
|
||||||
"value_obfuscated": None,
|
"value_obfuscated": None,
|
||||||
"value_plain": None,
|
"is_set": False,
|
||||||
}
|
}
|
||||||
if api["env_key"]:
|
if api["env_key"]:
|
||||||
raw = os.environ.get(api["env_key"], "")
|
raw = os.environ.get(api["env_key"], "")
|
||||||
entry["value_obfuscated"] = _obfuscate(raw)
|
entry["value_obfuscated"] = _obfuscate(raw)
|
||||||
entry["value_plain"] = raw # Sent only when reveal is requested
|
entry["is_set"] = bool(raw)
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def update_api_key(env_key: str, new_value: str) -> bool:
|
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."""
|
"""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
|
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
|
# Update os.environ immediately
|
||||||
os.environ[env_key] = new_value
|
os.environ[env_key] = new_value
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ class OpenSkyClient:
|
|||||||
|
|
||||||
# User provided credentials
|
# User provided credentials
|
||||||
opensky_client = OpenSkyClient(
|
opensky_client = OpenSkyClient(
|
||||||
client_id=os.environ.get("OPENSKY_CLIENT_ID", "vancecook-api-client"),
|
client_id=os.environ.get("OPENSKY_CLIENT_ID", ""),
|
||||||
client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", "YOUR_OPENSKY_SECRET")
|
client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Throttling and caching for OpenSky to observe the 400 req/day limit
|
# Throttling and caching for OpenSky to observe the 400 req/day limit
|
||||||
@@ -100,7 +100,8 @@ latest_data = {
|
|||||||
"uavs": [],
|
"uavs": [],
|
||||||
"frontlines": None,
|
"frontlines": None,
|
||||||
"gdelt": [],
|
"gdelt": [],
|
||||||
"liveuamap": []
|
"liveuamap": [],
|
||||||
|
"kiwisdr": []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Thread lock for safe reads/writes to latest_data
|
# 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
|
by_icao[id(f)] = f # no icao — keep as unique
|
||||||
return list(by_icao.values())
|
return list(by_icao.values())
|
||||||
|
|
||||||
latest_data['commercial_flights'] = _merge_category(commercial, latest_data.get('commercial_flights', []))
|
with _data_lock:
|
||||||
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
|
latest_data['commercial_flights'] = _merge_category(commercial, latest_data.get('commercial_flights', []))
|
||||||
latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_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)
|
# Always write raw flights for GPS jamming analysis (nac_p field)
|
||||||
if flights:
|
if flights:
|
||||||
@@ -964,27 +966,39 @@ def fetch_flights():
|
|||||||
all_lists = [commercial, private_jets, private_ga, existing_tracked]
|
all_lists = [commercial, private_jets, private_ga, existing_tracked]
|
||||||
seen_hexes = set()
|
seen_hexes = set()
|
||||||
trail_count = 0
|
trail_count = 0
|
||||||
for flist in all_lists:
|
with _trails_lock:
|
||||||
for f in flist:
|
for flist in all_lists:
|
||||||
count, hex_id = _accumulate_trail(f, now_ts, check_route=True)
|
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
|
trail_count += count
|
||||||
if hex_id:
|
if hex_id:
|
||||||
seen_hexes.add(hex_id)
|
seen_hexes.add(hex_id)
|
||||||
|
|
||||||
# Also process military flights (separate list)
|
# Prune stale trails (10 min for non-tracked, 30 min for tracked)
|
||||||
for mf in latest_data.get('military_flights', []):
|
tracked_hexes = {t.get('icao24', '').lower() for t in latest_data.get('tracked_flights', [])}
|
||||||
count, hex_id = _accumulate_trail(mf, now_ts, check_route=False)
|
stale_keys = []
|
||||||
trail_count += count
|
for k, v in flight_trails.items():
|
||||||
if hex_id:
|
cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 600
|
||||||
seen_hexes.add(hex_id)
|
if v['last_seen'] < cutoff:
|
||||||
|
stale_keys.append(k)
|
||||||
# Prune trails for aircraft not seen in 30 minutes
|
for k in stale_keys:
|
||||||
stale_cutoff = now_ts - 1800
|
del flight_trails[k]
|
||||||
stale_keys = [k for k, v in flight_trails.items() if v['last_seen'] < stale_cutoff]
|
|
||||||
for k in stale_keys:
|
# Enforce global cap — evict oldest trails first
|
||||||
del flight_trails[k]
|
if len(flight_trails) > _MAX_TRACKED_TRAILS:
|
||||||
|
sorted_keys = sorted(flight_trails.keys(), key=lambda k: flight_trails[k]['last_seen'])
|
||||||
logger.info(f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned")
|
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
|
# 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}")
|
logger.error(f"Error fetching cctv from DB: {e}")
|
||||||
latest_data["cctv"] = []
|
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():
|
def fetch_bikeshare():
|
||||||
bikes = []
|
bikes = []
|
||||||
try:
|
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"}),
|
("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():
|
def fetch_satellites():
|
||||||
sats = []
|
sats = []
|
||||||
try:
|
try:
|
||||||
@@ -1356,16 +1456,40 @@ def fetch_satellites():
|
|||||||
# Positions are re-propagated from cached orbital elements each cycle
|
# Positions are re-propagated from cached orbital elements each cycle
|
||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
if _sat_gp_cache["data"] is None or (now_ts - _sat_gp_cache["last_fetch"]) > 1800:
|
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"
|
# Try multiple CelesTrak mirrors — .org is often blocked/banned by some networks
|
||||||
response = fetch_with_curl(url, timeout=15)
|
gp_urls = [
|
||||||
if response.status_code == 200:
|
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
||||||
_sat_gp_cache["data"] = response.json()
|
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
||||||
_sat_gp_cache["last_fetch"] = now_ts
|
]
|
||||||
logger.info(f"Satellites: Downloaded {len(_sat_gp_cache['data'])} GP records from CelesTrak")
|
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"]
|
data = _sat_gp_cache["data"]
|
||||||
if not 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
|
latest_data["satellites"] = sats
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1412,7 +1536,7 @@ def fetch_satellites():
|
|||||||
ma = s.get('MEAN_ANOMALY')
|
ma = s.get('MEAN_ANOMALY')
|
||||||
bstar = s.get('BSTAR', 0)
|
bstar = s.get('BSTAR', 0)
|
||||||
epoch_str = s.get('EPOCH')
|
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:
|
if mean_motion is None or ecc is None or incl is None:
|
||||||
continue
|
continue
|
||||||
@@ -1567,6 +1691,8 @@ def fetch_uavs():
|
|||||||
|
|
||||||
cached_airports = []
|
cached_airports = []
|
||||||
flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}}
|
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)
|
# (math imported at module top)
|
||||||
|
|
||||||
@@ -1688,6 +1814,7 @@ def update_slow_data():
|
|||||||
fetch_cctv,
|
fetch_cctv,
|
||||||
fetch_earthquakes,
|
fetch_earthquakes,
|
||||||
fetch_geopolitics,
|
fetch_geopolitics,
|
||||||
|
fetch_kiwisdr,
|
||||||
]
|
]
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
||||||
futures = [executor.submit(func) for func in slow_funcs]
|
futures = [executor.submit(func) for func in slow_funcs]
|
||||||
@@ -1751,5 +1878,6 @@ def stop_scheduler():
|
|||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
|
|
||||||
def get_latest_data():
|
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]
|
headlines = [_url_to_headline(u) for u in urls]
|
||||||
f["properties"]["_urls_list"] = urls
|
f["properties"]["_urls_list"] = urls
|
||||||
f["properties"]["_headlines_list"] = headlines
|
f["properties"]["_headlines_list"] = headlines
|
||||||
|
import html
|
||||||
# Keep html as fallback
|
# Keep html as fallback
|
||||||
if urls:
|
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)
|
f["properties"]["html"] = ''.join(links)
|
||||||
else:
|
else:
|
||||||
f["properties"]["html"] = f["properties"]["name"]
|
f["properties"]["html"] = html.escape(f["properties"]["name"])
|
||||||
f.pop("_loc_key", None)
|
f.pop("_loc_key", None)
|
||||||
|
|
||||||
logger.info(f"GDELT multi-file parsed: {len(features)} conflict locations from {successful} files")
|
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 subprocess
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
import requests
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Find bash for curl fallback — Git bash's curl has the TLS features
|
||||||
# needed to pass CDN fingerprint checks (brotli, zstd, libpsl)
|
# needed to pass CDN fingerprint checks (brotli, zstd, libpsl)
|
||||||
_BASH_PATH = shutil.which("bash") or "bash"
|
_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
|
pass # Fall through to curl below
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
import requests
|
|
||||||
if method == "POST":
|
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:
|
else:
|
||||||
res = requests.get(url, timeout=timeout, headers=default_headers)
|
res = _session.get(url, timeout=timeout, headers=default_headers)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
# Clear failure cache on success
|
# Clear failure cache on success
|
||||||
_domain_fail_cache.pop(domain, None)
|
_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...")
|
logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...")
|
||||||
_domain_fail_cache[domain] = time.time()
|
_domain_fail_cache[domain] = time.time()
|
||||||
|
|
||||||
# Build curl command string for bash execution
|
# Build curl as argument list — never pass through shell to prevent injection
|
||||||
header_flags = " ".join(f'-H "{k}: {v}"' for k, v in default_headers.items())
|
_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:
|
if method == "POST" and json_data:
|
||||||
payload = json.dumps(json_data).replace('"', '\\"')
|
cmd += ["-X", "POST", "-H", "Content-Type: application/json",
|
||||||
curl_cmd = f'curl -s -w "\\n%{{http_code}}" {header_flags} -X POST -H "Content-Type: application/json" -d "{payload}" "{url}"'
|
"--data-binary", "@-"]
|
||||||
else:
|
cmd.append(url)
|
||||||
curl_cmd = f'curl -s -w "\\n%{{http_code}}" {header_flags} "{url}"'
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
stdin_data = json.dumps(json_data) if (method == "POST" and json_data) else None
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
[_BASH_PATH, "-c", curl_cmd],
|
cmd, capture_output=True, text=True, timeout=timeout + 5,
|
||||||
capture_output=True, text=True, timeout=timeout + 5
|
input=stdin_data
|
||||||
)
|
)
|
||||||
if res.returncode == 0 and res.stdout.strip():
|
if res.returncode == 0 and res.stdout.strip():
|
||||||
# Parse HTTP status code from -w output (last line)
|
# 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:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- AISSTREAM_API_KEY=${AISSTREAM_API_KEY}
|
- AIS_API_KEY=${AIS_API_KEY}
|
||||||
- N2YO_API_KEY=${N2YO_API_KEY}
|
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
||||||
- OPENSKY_USERNAME=${OPENSKY_USERNAME}
|
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
||||||
- OPENSKY_PASSWORD=${OPENSKY_PASSWORD}
|
|
||||||
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
@@ -20,11 +19,13 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
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
|
container_name: shadowbroker-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
|
||||||
- NEXT_PUBLIC_API_URL=http://localhost:8000
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY package*.json ./
|
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 . .
|
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
|
FROM base AS runner
|
||||||
EXPOSE 3000
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV production
|
||||||
# Next.js telemetry disable
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
# Start development server
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
CMD ["npm", "run", "dev:frontend"]
|
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
|
```bash
|
||||||
npm run dev
|
npm install
|
||||||
# or
|
npm run dev # http://localhost:3000
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
### Setting the variable
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
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 = {
|
const nextConfig: NextConfig = {
|
||||||
transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'],
|
transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'],
|
||||||
|
output: "standalone",
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+74
-165
@@ -1,24 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/leaflet": "^1.9.21",
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
"@types/mapbox-gl": "^3.4.1",
|
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"leaflet": "^1.9.4",
|
"hls.js": "^1.6.15",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"mapbox-gl": "^3.19.0",
|
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-leaflet": "^5.0.0",
|
|
||||||
"react-map-gl": "^8.1.0",
|
"react-map-gl": "^8.1.0",
|
||||||
"satellite.js": "^6.0.2"
|
"satellite.js": "^6.0.2"
|
||||||
},
|
},
|
||||||
@@ -1054,12 +1051,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/@mapbox/point-geometry": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
"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==",
|
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||||
"license": "BSD-2-Clause"
|
"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": {
|
"node_modules/@mapbox/whoots-js": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||||
@@ -1303,17 +1283,6 @@
|
|||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -1553,6 +1522,70 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||||
@@ -1648,15 +1681,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/mapbox__point-geometry": {
|
||||||
"version": "1.0.87",
|
"version": "1.0.87",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-1.0.87.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-1.0.87.tgz",
|
||||||
@@ -1678,15 +1702,6 @@
|
|||||||
"@types/pbf": "*"
|
"@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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.33",
|
"version": "20.19.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||||
@@ -2863,12 +2878,6 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@@ -2980,12 +2989,6 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -4272,12 +4275,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -4389,6 +4386,12 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -5101,12 +5104,6 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -5447,45 +5444,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/maplibre-gl": {
|
||||||
"version": "4.7.1",
|
"version": "4.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
|
||||||
@@ -5587,17 +5545,6 @@
|
|||||||
"pbf": "bin/pbf"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -6061,18 +6008,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6230,20 +6165,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-map-gl": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.0.tgz",
|
||||||
@@ -6383,12 +6304,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@@ -6784,12 +6699,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/split-string": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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:frontend": "next dev",
|
||||||
"dev:backend": "cd ../backend && venv\\Scripts\\python.exe main.py",
|
"dev:backend": "node ../start-backend.js",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/leaflet": "^1.9.21",
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
"@types/mapbox-gl": "^3.4.1",
|
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"leaflet": "^1.9.4",
|
"hls.js": "^1.6.15",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"mapbox-gl": "^3.19.0",
|
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-leaflet": "^5.0.0",
|
|
||||||
"react-map-gl": "^8.1.0",
|
"react-map-gl": "^8.1.0",
|
||||||
"satellite.js": "^6.0.2"
|
"satellite.js": "^6.0.2"
|
||||||
},
|
},
|
||||||
@@ -37,4 +34,4 @@
|
|||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,40 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #000000;
|
||||||
--foreground: #171717;
|
--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 {
|
@theme inline {
|
||||||
@@ -12,13 +44,6 @@
|
|||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
@@ -35,12 +60,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.styled-scrollbar::-webkit-scrollbar-thumb {
|
.styled-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: rgba(100, 116, 139, 0.3);
|
background: var(--scrollbar-thumb);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(100, 116, 139, 0.5);
|
background: var(--scrollbar-thumb-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.styled-scrollbar {
|
.styled-scrollbar {
|
||||||
@@ -70,4 +95,4 @@ body {
|
|||||||
/* Keep popups fully bright and interactive above the dimmed canvas */
|
/* Keep popups fully bright and interactive above the dimmed canvas */
|
||||||
.map-focus-active .maplibregl-popup {
|
.map-focus-active .maplibregl-popup {
|
||||||
z-index: 10 !important;
|
z-index: 10 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { ThemeProvider } from "@/lib/ThemeContext";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
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" />
|
<link href="https://cesium.com/downloads/cesiumjs/releases/1.115/Build/Cesium/Widgets/widgets.css" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-black`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[var(--bg-primary)]`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{children}
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+181
-27
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { API_BASE } from "@/lib/api";
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
@@ -14,10 +15,106 @@ import SettingsPanel from "@/components/SettingsPanel";
|
|||||||
import MapLegend from "@/components/MapLegend";
|
import MapLegend from "@/components/MapLegend";
|
||||||
import ScaleBar from "@/components/ScaleBar";
|
import ScaleBar from "@/components/ScaleBar";
|
||||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
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
|
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
|
||||||
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
|
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() {
|
export default function Dashboard() {
|
||||||
const dataRef = useRef<any>({});
|
const dataRef = useRef<any>({});
|
||||||
const [dataVersion, setDataVersion] = useState(0);
|
const [dataVersion, setDataVersion] = useState(0);
|
||||||
@@ -46,19 +143,33 @@ export default function Dashboard() {
|
|||||||
global_incidents: true,
|
global_incidents: true,
|
||||||
day_night: true,
|
day_night: true,
|
||||||
gps_jamming: 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({
|
const [effects, setEffects] = useState({
|
||||||
bloom: true,
|
bloom: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [activeStyle, setActiveStyle] = useState('DEFAULT');
|
const [activeStyle, setActiveStyle] = useState('DEFAULT');
|
||||||
const stylesList = ['DEFAULT', 'FLIR', 'NVG', 'CRT'];
|
const stylesList = ['DEFAULT', 'SATELLITE', 'FLIR', 'NVG', 'CRT'];
|
||||||
|
|
||||||
const cycleStyle = () => {
|
const cycleStyle = () => {
|
||||||
setActiveStyle((prev) => {
|
setActiveStyle((prev) => {
|
||||||
const idx = stylesList.indexOf(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
|
// Mouse coordinate + reverse geocoding state
|
||||||
const [mouseCoords, setMouseCoords] = useState<{ lat: number, lng: number } | null>(null);
|
const [mouseCoords, setMouseCoords] = useState<{ lat: number, lng: number } | null>(null);
|
||||||
const [locationLabel, setLocationLabel] = useState('');
|
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 geocodeCache = useRef<Map<string, string>>(new Map());
|
||||||
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -146,11 +262,19 @@ export default function Dashboard() {
|
|||||||
setRegionDossierLoading(true);
|
setRegionDossierLoading(true);
|
||||||
setRegionDossier(null);
|
setRegionDossier(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:8000/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`);
|
const [dossierRes, sentinelRes] = await Promise.allSettled([
|
||||||
if (res.ok) {
|
fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`),
|
||||||
const data = await res.json();
|
fetch(`${API_BASE}/api/sentinel2/search?lat=${coords.lat}&lng=${coords.lng}`),
|
||||||
setRegionDossier(data);
|
]);
|
||||||
|
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) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch region dossier", e);
|
console.error("Failed to fetch region dossier", e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,9 +299,10 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||||
const res = await fetch("http://localhost:8000/api/live-data/fast", { headers });
|
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
|
||||||
if (res.status === 304) return; // Data unchanged, skip update
|
if (res.status === 304) { setBackendStatus('connected'); return; }
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
setBackendStatus('connected');
|
||||||
fastEtag.current = res.headers.get('etag') || null;
|
fastEtag.current = res.headers.get('etag') || null;
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
dataRef.current = { ...dataRef.current, ...json };
|
dataRef.current = { ...dataRef.current, ...json };
|
||||||
@@ -185,6 +310,7 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed fetching fast live data", e);
|
console.error("Failed fetching fast live data", e);
|
||||||
|
setBackendStatus('disconnected');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,7 +318,7 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
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.status === 304) return;
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
slowEtag.current = res.headers.get('etag') || null;
|
slowEtag.current = res.headers.get('etag') || null;
|
||||||
@@ -208,10 +334,10 @@ export default function Dashboard() {
|
|||||||
fetchFastData();
|
fetchFastData();
|
||||||
fetchSlowData();
|
fetchSlowData();
|
||||||
|
|
||||||
// Fast polling: 15s (backend updates every 60s — polling more often just yields 304s)
|
// Fast polling: 60s (matches backend update cadence — was 15s, wasting 75% on 304s)
|
||||||
// Slow polling: 60s (backend updates every 30min)
|
// Slow polling: 120s (backend updates every 30min)
|
||||||
const fastInterval = setInterval(fetchFastData, 15000);
|
const fastInterval = setInterval(fetchFastData, 60000);
|
||||||
const slowInterval = setInterval(fetchSlowData, 60000);
|
const slowInterval = setInterval(fetchSlowData, 120000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(fastInterval);
|
clearInterval(fastInterval);
|
||||||
@@ -220,7 +346,7 @@ export default function Dashboard() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* MAPLIBRE WEBGL OVERLAY */}
|
||||||
<ErrorBoundary name="Map">
|
<ErrorBoundary name="Map">
|
||||||
@@ -232,6 +358,8 @@ export default function Dashboard() {
|
|||||||
onEntityClick={setSelectedEntity}
|
onEntityClick={setSelectedEntity}
|
||||||
selectedEntity={selectedEntity}
|
selectedEntity={selectedEntity}
|
||||||
flyToLocation={flyToLocation}
|
flyToLocation={flyToLocation}
|
||||||
|
gibsDate={gibsDate}
|
||||||
|
gibsOpacity={gibsOpacity}
|
||||||
isEavesdropping={isEavesdropping}
|
isEavesdropping={isEavesdropping}
|
||||||
onEavesdropClick={setEavesdropLocation}
|
onEavesdropClick={setEavesdropLocation}
|
||||||
onCameraMove={setCameraCenter}
|
onCameraMove={setCameraCenter}
|
||||||
@@ -266,10 +394,10 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<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>
|
S H A D O W <span className="text-cyan-400">B R O K E R</span>
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -279,7 +407,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SYSTEM METRICS TOP RIGHT */}
|
{/* 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>RTX</div>
|
||||||
<div>VSR</div>
|
<div>VSR</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,7 +415,7 @@ export default function Dashboard() {
|
|||||||
{/* LEFT HUD CONTAINER */}
|
{/* LEFT HUD CONTAINER */}
|
||||||
<div className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none">
|
<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 */}
|
{/* 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 */}
|
{/* LEFT BOTTOM - DISPLAY CONFIG */}
|
||||||
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
|
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
|
||||||
@@ -327,6 +455,7 @@ export default function Dashboard() {
|
|||||||
setIsEavesdropping={setIsEavesdropping}
|
setIsEavesdropping={setIsEavesdropping}
|
||||||
eavesdropLocation={eavesdropLocation}
|
eavesdropLocation={eavesdropLocation}
|
||||||
cameraCenter={cameraCenter}
|
cameraCenter={cameraCenter}
|
||||||
|
selectedEntity={selectedEntity}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -346,37 +475,40 @@ export default function Dashboard() {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 1, duration: 1 }}
|
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
|
<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}
|
onClick={cycleStyle}
|
||||||
>
|
>
|
||||||
{/* Coordinates */}
|
{/* Coordinates */}
|
||||||
<div className="flex flex-col items-center min-w-[120px]">
|
<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">
|
<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'}
|
{mouseCoords ? `${mouseCoords.lat.toFixed(4)}, ${mouseCoords.lng.toFixed(4)}` : '0.0000, 0.0000'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="w-px h-8 bg-gray-700" />
|
<div className="w-px h-8 bg-[var(--border-primary)]" />
|
||||||
|
|
||||||
{/* Location name */}
|
{/* Location name */}
|
||||||
<div className="flex flex-col items-center min-w-[180px] max-w-[320px]">
|
<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-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">LOCATION</div>
|
||||||
<div className="text-[10px] text-gray-300 font-mono truncate max-w-[320px]">
|
<div className="text-[10px] text-[var(--text-secondary)] font-mono truncate max-w-[320px]">
|
||||||
{locationLabel || 'Hover over map...'}
|
{locationLabel || 'Hover over map...'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="w-px h-8 bg-gray-700" />
|
<div className="w-px h-8 bg-[var(--border-primary)]" />
|
||||||
|
|
||||||
{/* Style preset (compact) */}
|
{/* Style preset (compact) */}
|
||||||
<div className="flex flex-col items-center">
|
<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 className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,7 +520,7 @@ export default function Dashboard() {
|
|||||||
{!uiVisible && (
|
{!uiVisible && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setUiVisible(true)}
|
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
|
RESTORE UI
|
||||||
</button>
|
</button>
|
||||||
@@ -425,6 +557,28 @@ export default function Dashboard() {
|
|||||||
{/* MAP LEGEND */}
|
{/* MAP LEGEND */}
|
||||||
<MapLegend isOpen={legendOpen} onClose={() => setLegendOpen(false)} />
|
<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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,16 +171,16 @@ export default function AdvancedFilterModal({
|
|||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.92 }}
|
exit={{ opacity: 0, scale: 0.92 }}
|
||||||
transition={{ duration: 0.2 }}
|
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' }}
|
style={{ maxHeight: '70vh' }}
|
||||||
>
|
>
|
||||||
{/* ── Title Bar (Draggable) ── */}
|
{/* ── Title Bar (Draggable) ── */}
|
||||||
<div
|
<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}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<GripHorizontal size={14} className="text-gray-600" />
|
<GripHorizontal size={14} className="text-[var(--text-muted)]" />
|
||||||
{icon}
|
{icon}
|
||||||
<span className={`text-[11px] ${c.text} tracking-[0.25em] font-semibold`}>{title}</span>
|
<span className={`text-[11px] ${c.text} tracking-[0.25em] font-semibold`}>{title}</span>
|
||||||
{totalSelected > 0 && (
|
{totalSelected > 0 && (
|
||||||
@@ -189,14 +189,14 @@ export default function AdvancedFilterModal({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tab Bar (for multi-field categories) ── */}
|
{/* ── Tab Bar (for multi-field categories) ── */}
|
||||||
{fields.length > 1 && (
|
{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 => {
|
{fields.map(field => {
|
||||||
const isActive = activeTab === field.key;
|
const isActive = activeTab === field.key;
|
||||||
const count = draft[field.key]?.size || 0;
|
const count = draft[field.key]?.size || 0;
|
||||||
@@ -257,7 +257,7 @@ export default function AdvancedFilterModal({
|
|||||||
value={searchTerms[activeTab] || ''}
|
value={searchTerms[activeTab] || ''}
|
||||||
onChange={(e) => setSearchTerms(prev => ({ ...prev, [activeTab]: e.target.value }))}
|
onChange={(e) => setSearchTerms(prev => ({ ...prev, [activeTab]: e.target.value }))}
|
||||||
placeholder={`Search ${activeField?.label.toLowerCase() || ''}...`}
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{searchTerms[activeTab] && (
|
{searchTerms[activeTab] && (
|
||||||
@@ -270,10 +270,10 @@ export default function AdvancedFilterModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-1.5">
|
<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
|
{filteredOptions.length} AVAILABLE
|
||||||
</span>
|
</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
|
{draft[activeTab]?.size || 0} SELECTED
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,7 +282,7 @@ export default function AdvancedFilterModal({
|
|||||||
{/* ── Scrollable Checkbox List ── */}
|
{/* ── Scrollable Checkbox List ── */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2 styled-scrollbar" style={{ maxHeight: '35vh' }}>
|
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2 styled-scrollbar" style={{ maxHeight: '35vh' }}>
|
||||||
{filteredOptions.length === 0 ? (
|
{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
|
NO MATCHING RESULTS
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -295,13 +295,13 @@ export default function AdvancedFilterModal({
|
|||||||
onClick={() => toggleItem(activeTab, option)}
|
onClick={() => toggleItem(activeTab, option)}
|
||||||
className={`flex items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-all group ${isChecked
|
className={`flex items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-all group ${isChecked
|
||||||
? `${c.bg} ${c.text}`
|
? `${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 */}
|
{/* Checkbox */}
|
||||||
<div className={`w-3.5 h-3.5 rounded-[3px] border flex items-center justify-center flex-shrink-0 transition-all ${isChecked
|
<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}`
|
? `${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} />}
|
{isChecked && <Check size={9} strokeWidth={3} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -316,7 +316,7 @@ export default function AdvancedFilterModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Footer ── */}
|
{/* ── 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
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="text-[9px] text-red-400/70 hover:text-red-300 tracking-widest transition-colors"
|
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">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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
|
CANCEL
|
||||||
</button>
|
</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="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-center font-mono">
|
||||||
<div className="text-red-400 text-xs tracking-widest mb-1">⚠ SYSTEM ERROR</div>
|
<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
|
<button
|
||||||
onClick={() => this.setState({ hasError: false, error: null })}
|
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"
|
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 }}
|
initial={{ y: -30, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.6, delay: 0.3 }}
|
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 */}
|
{/* Header Toggle */}
|
||||||
<div
|
<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)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter size={12} className="text-cyan-500" />
|
<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 && (
|
{activeCount > 0 && (
|
||||||
<span className="text-[9px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-sm">
|
<span className="text-[9px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-sm">
|
||||||
{activeCount} ACTIVE
|
{activeCount} ACTIVE
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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} />}
|
{isMinimized ? <SlidersHorizontal size={14} /> : <ChevronUp size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,20 +295,20 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={section.key}
|
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)}
|
onClick={() => setOpenModal(section.key)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-2.5 px-3">
|
<div className="flex items-center justify-between p-2.5 px-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{section.icon}
|
{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 && (
|
{count > 0 && (
|
||||||
<span className={`text-[8px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}>
|
<span className={`text-[8px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -171,14 +171,14 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative w-full pointer-events-auto">
|
<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">
|
<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-gray-500 flex-shrink-0" />
|
<Search size={12} className="text-[var(--text-muted)] flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
placeholder="Find aircraft or vessel..."
|
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) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
@@ -186,11 +186,11 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
|||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => setIsOpen(true)}
|
||||||
/>
|
/>
|
||||||
{query && (
|
{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} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Crosshair size={12} className="text-gray-600 flex-shrink-0" />
|
<Crosshair size={12} className="text-[var(--text-muted)] flex-shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -199,21 +199,21 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
|||||||
initial={{ opacity: 0, y: -4 }}
|
initial={{ opacity: 0, y: -4 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -4 }}
|
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">
|
<div className="max-h-[300px] overflow-y-auto styled-scrollbar">
|
||||||
{filtered.map((r, idx) => (
|
{filtered.map((r, idx) => (
|
||||||
<button
|
<button
|
||||||
key={`${r.id}-${idx}`}
|
key={`${r.id}-${idx}`}
|
||||||
onClick={() => handleSelect(r)}
|
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]}
|
{categoryIcons[r.category]}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<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-[10px] text-[var(--text-primary)] font-mono tracking-wide truncate">{r.label}</div>
|
||||||
<div className="text-[8px] text-gray-500 font-mono truncate">{r.sublabel}</div>
|
<div className="text-[8px] text-[var(--text-muted)] font-mono truncate">{r.sublabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[7px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}>
|
<span className={`text-[7px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}>
|
||||||
{r.category}
|
{r.category}
|
||||||
@@ -221,7 +221,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
{filtered.length} RESULT{filtered.length !== 1 ? 'S' : ''} — CLICK TO LOCATE
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -231,9 +231,9 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
|
|||||||
initial={{ opacity: 0, y: -4 }}
|
initial={{ opacity: 0, y: -4 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -4 }}
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
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 */}
|
{/* 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="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">
|
<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">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">MAP LEGEND</h2>
|
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">MAP LEGEND</h2>
|
||||||
<span className="text-[9px] text-gray-500 font-mono tracking-widest">ICON REFERENCE KEY</span>
|
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">ICON REFERENCE KEY</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -247,16 +247,16 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
|
|||||||
{LEGEND.map((cat) => {
|
{LEGEND.map((cat) => {
|
||||||
const isCollapsed = collapsed.has(cat.name);
|
const isCollapsed = collapsed.has(cat.name);
|
||||||
return (
|
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 */}
|
{/* Category Header */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(cat.name)}
|
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}`}>
|
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${cat.color}`}>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
|
|
||||||
{/* Items */}
|
{/* Items */}
|
||||||
@@ -267,13 +267,13 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
|
|||||||
animate={{ height: "auto", opacity: 1 }}
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
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">
|
<div className="grid grid-cols-1 gap-0">
|
||||||
{cat.items.map((item, idx) => (
|
{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} />
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -286,8 +286,8 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-3 border-t border-gray-800/80 flex-shrink-0">
|
<div className="p-3 border-t border-[var(--border-primary)]/80 flex-shrink-0">
|
||||||
<div className="text-[9px] text-gray-600 font-mono text-center tracking-wider">
|
<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
|
{LEGEND.reduce((sum, c) => sum + c.items.length, 0)} ICON DEFINITIONS ACROSS {LEGEND.length} CATEGORIES
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { API_BASE } from "@/lib/api";
|
||||||
import React, { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
import React, { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Map, { Source, Layer, MapRef, ViewState, Popup, Marker } from "react-map-gl/maplibre";
|
import Map, { Source, Layer, MapRef, ViewState, Popup, Marker } from "react-map-gl/maplibre";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
@@ -8,6 +9,7 @@ import ScaleBar from "@/components/ScaleBar";
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import WikiImage from "@/components/WikiImage";
|
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 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>`)}`;
|
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 ---
|
// --- Smooth position interpolation helpers ---
|
||||||
// Given heading (degrees) and speed (knots), compute new lat/lng after dt seconds
|
// 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];
|
if (!speedKnots || speedKnots <= 0 || dtSeconds <= 0) return [lat, lng];
|
||||||
// Cap interpolation to max 6 seconds to prevent runaway drift when data is stale
|
// Cap interpolation time to prevent runaway drift when data is stale
|
||||||
const clampedDt = Math.min(dtSeconds, 6);
|
const clampedDt = Math.min(dtSeconds, maxDt);
|
||||||
// 1 knot = 1 nautical mile/hour = 1852 m/h
|
// 1 knot = 1 nautical mile/hour = 1852 m/h
|
||||||
const speedMps = speedKnots * 0.5144; // meters per second
|
const speedMps = speedKnots * 0.5144; // meters per second
|
||||||
const dist = maxDist > 0 ? Math.min(speedMps * clampedDt, maxDist) : speedMps * clampedDt;
|
const dist = maxDist > 0 ? Math.min(speedMps * clampedDt, maxDist) : speedMps * clampedDt;
|
||||||
@@ -149,13 +151,29 @@ const darkStyle = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
layers: [
|
layers: [
|
||||||
{
|
{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 },
|
||||||
id: 'carto-dark-layer',
|
{ 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',
|
type: 'raster',
|
||||||
source: 'carto-dark',
|
tiles: [
|
||||||
minzoom: 0,
|
"https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
|
||||||
maxzoom: 22
|
"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'
|
'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 mapRef = useRef<MapRef>(null);
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const mapThemeStyle = useMemo(() => theme === 'light' ? lightStyle : darkStyle, [theme]);
|
||||||
|
|
||||||
const [viewState, setViewState] = useState<ViewState>({
|
const [viewState, setViewState] = useState<ViewState>({
|
||||||
longitude: 0,
|
longitude: 0,
|
||||||
@@ -231,14 +251,15 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
const [interpTick, setInterpTick] = useState(0);
|
const [interpTick, setInterpTick] = useState(0);
|
||||||
const dataTimestamp = useRef<number>(Date.now());
|
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(() => {
|
useEffect(() => {
|
||||||
dataTimestamp.current = Date.now();
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setInterpTick(t => t + 1), 10000);
|
const timer = setInterval(() => setInterpTick(t => t + 1), 2000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -267,7 +288,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
|
|
||||||
if (callsign && callsign !== prevCallsign.current) {
|
if (callsign && callsign !== prevCallsign.current) {
|
||||||
prevCallsign.current = callsign;
|
prevCallsign.current = callsign;
|
||||||
fetch(`http://localhost:8000/api/route/${callsign}`)
|
fetch(`${API_BASE}/api/route/${callsign}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(routeData => {
|
.then(routeData => {
|
||||||
if (isMounted) setDynamicRoute(routeData);
|
if (isMounted) setDynamicRoute(routeData);
|
||||||
@@ -367,19 +388,64 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
};
|
};
|
||||||
}, [activeLayers.cctv, data?.cctv, inView]);
|
}, [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
|
// Load Images into the Map Style once loaded
|
||||||
const onMapLoad = useCallback((e: any) => {
|
const onMapLoad = useCallback((e: any) => {
|
||||||
const map = e.target;
|
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) => {
|
const loadImg = (id: string, url: string) => {
|
||||||
if (!map.hasImage(id)) {
|
if (!map.hasImage(id)) {
|
||||||
|
pendingImages[id] = url;
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = "anonymous";
|
img.crossOrigin = "anonymous";
|
||||||
img.src = url;
|
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)
|
// Legacy generic plane icons (still used as fallbacks)
|
||||||
loadImg('svgPlaneCyan', svgPlaneCyan);
|
loadImg('svgPlaneCyan', svgPlaneCyan);
|
||||||
loadImg('svgPlaneYellow', svgPlaneYellow);
|
loadImg('svgPlaneYellow', svgPlaneYellow);
|
||||||
@@ -498,10 +564,12 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
return [newLng, newLat];
|
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] => {
|
const interpSat = (s: any): [number, number] => {
|
||||||
if (!s.speed_knots || s.speed_knots <= 0 || dtSeconds < 1) return [s.lng, s.lat];
|
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];
|
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]);
|
}, [activeLayers.ships_important, activeLayers.ships_civilian, activeLayers.ships_passenger, data?.ships, inView]);
|
||||||
|
|
||||||
// Extract ship cluster positions from the map source for HTML labels
|
// Extract ship cluster positions from the map source for HTML labels
|
||||||
|
const shipClusterHandlerRef = useRef<(() => void) | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current?.getMap();
|
const map = mapRef.current?.getMap();
|
||||||
if (!map || !shipsGeoJSON) { setShipClusters([]); return; }
|
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 = () => {
|
const update = () => {
|
||||||
try {
|
try {
|
||||||
const features = map.querySourceFeatures('ships');
|
const features = map.querySourceFeatures('ships');
|
||||||
@@ -689,6 +764,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
setShipClusters(unique);
|
setShipClusters(unique);
|
||||||
} catch { setShipClusters([]); }
|
} catch { setShipClusters([]); }
|
||||||
};
|
};
|
||||||
|
shipClusterHandlerRef.current = update;
|
||||||
|
|
||||||
map.on('moveend', update);
|
map.on('moveend', update);
|
||||||
map.on('sourcedata', update);
|
map.on('sourcedata', update);
|
||||||
@@ -698,10 +774,16 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
}, [shipsGeoJSON]);
|
}, [shipsGeoJSON]);
|
||||||
|
|
||||||
// Extract earthquake cluster positions from the map source for HTML labels
|
// Extract earthquake cluster positions from the map source for HTML labels
|
||||||
|
const eqClusterHandlerRef = useRef<(() => void) | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current?.getMap();
|
const map = mapRef.current?.getMap();
|
||||||
if (!map || !earthquakesGeoJSON) { setEqClusters([]); return; }
|
if (!map || !earthquakesGeoJSON) { setEqClusters([]); return; }
|
||||||
|
|
||||||
|
if (eqClusterHandlerRef.current) {
|
||||||
|
map.off('moveend', eqClusterHandlerRef.current);
|
||||||
|
map.off('sourcedata', eqClusterHandlerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
try {
|
try {
|
||||||
const features = map.querySourceFeatures('earthquakes');
|
const features = map.querySourceFeatures('earthquakes');
|
||||||
@@ -718,6 +800,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
setEqClusters(unique);
|
setEqClusters(unique);
|
||||||
} catch { setEqClusters([]); }
|
} catch { setEqClusters([]); }
|
||||||
};
|
};
|
||||||
|
eqClusterHandlerRef.current = update;
|
||||||
|
|
||||||
map.on('moveend', update);
|
map.on('moveend', update);
|
||||||
map.on('sourcedata', update);
|
map.on('sourcedata', update);
|
||||||
@@ -783,7 +866,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}, [selectedEntity, data, dynamicRoute]);
|
}, [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(() => {
|
const trailGeoJSON = useMemo(() => {
|
||||||
if (!selectedEntity || !data) return null;
|
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];
|
else if (selectedEntity.type === 'tracked_flight') entity = data?.tracked_flights?.[selectedEntity.id as number];
|
||||||
|
|
||||||
if (!entity || !entity.trail || entity.trail.length < 2) return null;
|
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]]);
|
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) {
|
if (entity.lat != null && entity.lng != null) {
|
||||||
coords.push([entity.lng, entity.lat]);
|
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 GAP = 6; // Minimum gap between boxes
|
||||||
const MAX_OFFSET = 350;
|
const MAX_OFFSET = 350;
|
||||||
|
|
||||||
// 2. Iterative Collision Resolution Loop
|
// 2. Grid-based Collision Resolution (O(n) per iteration instead of O(n²))
|
||||||
const maxIter = 40;
|
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++) {
|
for (let iter = 0; iter < maxIter; iter++) {
|
||||||
let moved = false;
|
let moved = false;
|
||||||
|
// Build spatial grid
|
||||||
|
const grid: Record<string, number[]> = {};
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
for (let j = i + 1; j < items.length; j++) {
|
const cx = Math.floor((items[i].x + items[i].offsetX) / CELL_W);
|
||||||
const a = items[i];
|
const cy = Math.floor((items[i].y + items[i].offsetY) / CELL_H);
|
||||||
const b = items[j];
|
const key = `${cx},${cy}`;
|
||||||
|
(grid[key] ??= []).push(i);
|
||||||
const aX = a.x + a.offsetX;
|
}
|
||||||
const aY = a.y + a.offsetY;
|
// Check collisions only within same/adjacent cells
|
||||||
const bX = b.x + b.offsetX;
|
const checked = new Set<string>();
|
||||||
const bY = b.y + b.offsetY;
|
for (const key in grid) {
|
||||||
|
const [cx, cy] = key.split(',').map(Number);
|
||||||
const dx = Math.abs(aX - bX);
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
const dy = Math.abs(aY - bY);
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
const nk = `${cx + dx},${cy + dy}`;
|
||||||
// Per-pair min distances using each box's actual estimated height
|
if (!grid[nk]) continue;
|
||||||
const minDistX = BOX_W + GAP;
|
const pairKey = cx + dx < cx || (cx + dx === cx && cy + dy < cy) ? `${nk}|${key}` : `${key}|${nk}`;
|
||||||
const minDistY = (a.boxH + b.boxH) / 2 + GAP;
|
if (key !== nk && checked.has(pairKey)) continue;
|
||||||
|
checked.add(pairKey);
|
||||||
if (dx < minDistX && dy < minDistY) {
|
const cellA = grid[key];
|
||||||
moved = true;
|
const cellB = key === nk ? cellA : grid[nk];
|
||||||
|
for (const i of cellA) {
|
||||||
const overlapX = minDistX - dx;
|
const startJ = key === nk ? cellA.indexOf(i) + 1 : 0;
|
||||||
const overlapY = minDistY - dy;
|
for (let jIdx = startJ; jIdx < cellB.length; jIdx++) {
|
||||||
|
const j = cellB[jIdx];
|
||||||
// Push each by half the overlap + 1px to guarantee separation
|
if (i === j) continue;
|
||||||
if (overlapY < overlapX) {
|
const a = items[i], b = items[j];
|
||||||
const push = (overlapY / 2) + 1;
|
const adx = Math.abs((a.x + a.offsetX) - (b.x + b.offsetX));
|
||||||
if (aY <= bY) { a.offsetY -= push; b.offsetY += push; }
|
const ady = Math.abs((a.y + a.offsetY) - (b.y + b.offsetY));
|
||||||
else { a.offsetY += push; b.offsetY -= push; }
|
const minDistX = BOX_W + GAP;
|
||||||
} else {
|
const minDistY = (a.boxH + b.boxH) / 2 + GAP;
|
||||||
const push = (overlapX / 2) + 1;
|
if (adx < minDistX && ady < minDistY) {
|
||||||
if (aX <= bX) { a.offsetX -= push; b.offsetX += push; }
|
moved = true;
|
||||||
else { a.offsetX += push; b.offsetX -= push; }
|
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 {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: data.uavs.map((uav: any, i: number) => {
|
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 {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -962,7 +1061,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
};
|
};
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
};
|
};
|
||||||
}, [activeLayers.military, data?.uavs]);
|
}, [activeLayers.military, data?.uavs, inView]);
|
||||||
|
|
||||||
// UAV operational range circle — only for the selected UAV
|
// UAV operational range circle — only for the selected UAV
|
||||||
const uavRangeGeoJSON = useMemo(() => {
|
const uavRangeGeoJSON = useMemo(() => {
|
||||||
@@ -996,6 +1095,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: data.gdelt.map((g: any, i: number) => {
|
features: data.gdelt.map((g: any, i: number) => {
|
||||||
if (!g.geometry || !g.geometry.coordinates) return null;
|
if (!g.geometry || !g.geometry.coordinates) return null;
|
||||||
|
const [gLng, gLat] = g.geometry.coordinates;
|
||||||
|
if (!inView(gLat, gLng)) return null;
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
properties: { id: i, type: 'gdelt', title: g.title },
|
properties: { id: i, type: 'gdelt', title: g.title },
|
||||||
@@ -1003,14 +1104,14 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
};
|
};
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
};
|
};
|
||||||
}, [activeLayers.global_incidents, data?.gdelt]);
|
}, [activeLayers.global_incidents, data?.gdelt, inView]);
|
||||||
|
|
||||||
const liveuaGeoJSON = useMemo(() => {
|
const liveuaGeoJSON = useMemo(() => {
|
||||||
if (!activeLayers.global_incidents || !data?.liveuamap) return null;
|
if (!activeLayers.global_incidents || !data?.liveuamap) return null;
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: data.liveuamap.map((incident: any, i: number) => {
|
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 || "");
|
const isViolent = /bomb|missil|strike|attack|kill|destroy|fire|shoot|expl|raid/i.test(incident.title || "");
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
@@ -1019,7 +1120,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
};
|
};
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
};
|
};
|
||||||
}, [activeLayers.global_incidents, data?.liveuamap]);
|
}, [activeLayers.global_incidents, data?.liveuamap, inView]);
|
||||||
|
|
||||||
const frontlineGeoJSON = useMemo(() => {
|
const frontlineGeoJSON = useMemo(() => {
|
||||||
if (!activeLayers.ukraine_frontline || !data?.frontlines) return null;
|
if (!activeLayers.ukraine_frontline || !data?.frontlines) return null;
|
||||||
@@ -1043,7 +1144,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
frontlineGeoJSON && 'ukraine-frontline-layer',
|
frontlineGeoJSON && 'ukraine-frontline-layer',
|
||||||
earthquakesGeoJSON && 'earthquakes-layer',
|
earthquakesGeoJSON && 'earthquakes-layer',
|
||||||
satellitesGeoJSON && 'satellites-layer',
|
satellitesGeoJSON && 'satellites-layer',
|
||||||
cctvGeoJSON && 'cctv-layer'
|
cctvGeoJSON && 'cctv-layer',
|
||||||
|
kiwisdrGeoJSON && 'kiwisdr-layer'
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
|
||||||
@@ -1075,7 +1177,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
onRightClick?.({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
|
onRightClick?.({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
|
||||||
}}
|
}}
|
||||||
mapStyle={darkStyle as any}
|
mapStyle={mapThemeStyle as any}
|
||||||
mapLib={maplibregl}
|
mapLib={maplibregl}
|
||||||
onLoad={onMapLoad}
|
onLoad={onMapLoad}
|
||||||
onIdle={updateBounds}
|
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 */}
|
{/* SOLAR TERMINATOR — night overlay */}
|
||||||
{activeLayers.day_night && nightGeoJSON && (
|
{activeLayers.day_night && nightGeoJSON && (
|
||||||
<Source id="night-overlay" type="geojson" data={nightGeoJSON as any}>
|
<Source id="night-overlay" type="geojson" data={nightGeoJSON as any}>
|
||||||
@@ -1723,6 +1869,43 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Source>
|
</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 */}
|
{/* Satellite positions — mission-type icons */}
|
||||||
{satellitesGeoJSON && (
|
{satellitesGeoJSON && (
|
||||||
<Source id="satellites" type="geojson" data={satellitesGeoJSON as any}>
|
<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>
|
Altitude: <span style={{ color: '#44ff88' }}>{sat.alt_km?.toLocaleString()} km</span>
|
||||||
</div>
|
</div>
|
||||||
{sat.wiki && (
|
{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" />
|
<WikiImage wikiUrl={sat.wiki} label={sat.sat_type || sat.name} maxH="max-h-28" accent="hover:border-cyan-500/50" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1844,7 +2027,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uav.wiki && (
|
{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" />
|
<WikiImage wikiUrl={uav.wiki} label={uav.callsign} maxH="max-h-28" accent="hover:border-red-500/50" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1863,25 +2046,25 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
anchor="bottom"
|
anchor="bottom"
|
||||||
offset={15}
|
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">
|
<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">
|
<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
|
<AlertTriangle size={12} className="text-orange-400" /> NEWS ON THE GROUND
|
||||||
</h2>
|
</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>
|
||||||
<div className="p-3 flex flex-col gap-2">
|
<div className="p-3 flex flex-col gap-2">
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-1">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
|
||||||
<span className="text-gray-500 text-[9px]">LOCATION</span>
|
<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>
|
<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>
|
||||||
<div className="flex flex-col gap-1 mt-1">
|
<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">
|
<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 urls: string[] = data.gdelt[selectedEntity.id as number].properties?._urls_list || [];
|
||||||
const headlines: string[] = data.gdelt[selectedEntity.id as number].properties?._headlines_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) => (
|
return urls.map((url: string, idx: number) => (
|
||||||
<a
|
<a
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -1889,7 +2072,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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' }}
|
style={{ pointerEvents: 'all' }}
|
||||||
>
|
>
|
||||||
{headlines[idx] || url}
|
{headlines[idx] || url}
|
||||||
@@ -1917,19 +2100,19 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
anchor="bottom"
|
anchor="bottom"
|
||||||
offset={15}
|
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">
|
<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">
|
<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
|
<AlertTriangle size={12} className="text-yellow-400" /> REGIONAL TACTICAL EVENT
|
||||||
</h2>
|
</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>
|
||||||
<div className="p-3 flex flex-col gap-2">
|
<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>
|
<span className="text-yellow-400 text-[10px] font-bold leading-tight">{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-1 mt-1">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1 mt-1">
|
||||||
<span className="text-gray-500 text-[9px]">TIME</span>
|
<span className="text-[var(--text-muted)] text-[9px]">TIME</span>
|
||||||
<span className="text-white text-[9px] font-bold">{item.timestamp || 'UNKNOWN'}</span>
|
<span className="text-white text-[9px] font-bold">{item.timestamp || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.link && (
|
{item.link && (
|
||||||
@@ -1977,22 +2160,22 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
anchor="bottom"
|
anchor="bottom"
|
||||||
offset={25}
|
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`}>
|
<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`}>
|
<h2 className={`text-[10px] tracking-widest font-bold ${threatColor} flex items-center gap-1`}>
|
||||||
<AlertTriangle size={12} className={threatColor} /> THREAT INTERCEPT
|
<AlertTriangle size={12} className={threatColor} /> THREAT INTERCEPT
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-[10px] ${threatColor} font-mono font-bold animate-pulse`}>LVL: {item.risk_score}/10</span>
|
<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>
|
</div>
|
||||||
<div className="p-3 flex flex-col gap-2">
|
<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>
|
<span className={`text-[10px] font-bold leading-tight ${threatColor}`}>{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-1 mt-1">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1 mt-1">
|
||||||
<span className="text-gray-500 text-[9px]">SOURCE</span>
|
<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>
|
<span className="text-white text-[9px] font-bold text-right ml-2">{item.source || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.machine_assessment && (
|
{item.machine_assessment && (
|
||||||
@@ -2037,6 +2220,65 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Marker>
|
</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 */}
|
{/* MEASUREMENT LINES */}
|
||||||
{measurePoints && measurePoints.length >= 2 && (
|
{measurePoints && measurePoints.length >= 2 && (
|
||||||
<Source id="measure-lines" type="geojson" data={{
|
<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 }}
|
initial={{ y: -50, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
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 */}
|
{/* Header Toggle */}
|
||||||
<div
|
<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)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">GLOBAL MARKETS</span>
|
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||||
<button className="text-gray-500 hover:text-white transition-colors">
|
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,7 +36,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
|
|||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
className="overflow-y-auto styled-scrollbar flex flex-col gap-4 p-4 pt-3 max-h-[400px]"
|
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">
|
<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
|
<TrendingUp className="text-cyan-500" size={14} /> DEFENSE SEC TICKERS
|
||||||
</h2>
|
</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">
|
<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>
|
<span className="font-bold text-cyan-300 z-10 text-[10px]">[{ticker}]</span>
|
||||||
<div className="flex items-center gap-3 text-right z-10">
|
<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'}`}>
|
<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} />}
|
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
||||||
{Math.abs(info.change_percent).toFixed(2)}%
|
{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">
|
<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>
|
<span className="font-bold text-cyan-500 text-[9px] uppercase mb-0.5">{name}</span>
|
||||||
<div className="flex items-center justify-between">
|
<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'}`}>
|
<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} />}
|
{info.up ? <ArrowUpRight size={10} /> : <ArrowDownRight size={10} />}
|
||||||
{Math.abs(info.change_percent).toFixed(2)}%
|
{Math.abs(info.change_percent).toFixed(2)}%
|
||||||
|
|||||||
@@ -3,9 +3,43 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { AlertTriangle, Clock, ChevronDown, ChevronUp } from 'lucide-react';
|
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';
|
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"
|
// Format time from pubish string "Tue, 24 Feb 2026 15:30:00 GMT" to "15:30"
|
||||||
function formatTime(pubDate: string) {
|
function formatTime(pubDate: string) {
|
||||||
try {
|
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">
|
<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>
|
<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)}` : ''}
|
{selectedEntity.extra ? `${selectedEntity.extra.lat.toFixed(3)}, ${selectedEntity.extra.lng.toFixed(3)}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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]">
|
<div className="p-3 flex flex-col gap-1.5 max-h-[500px] overflow-y-auto styled-scrollbar text-[10px]">
|
||||||
{/* COUNTRY */}
|
{/* 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="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 && (
|
{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-[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-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-[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-gray-500">POPULATION</span><span className="text-white font-bold">{d.country?.population?.toLocaleString()}</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-gray-500">CAPITAL</span><span className="text-white font-bold">{d.country?.capital}</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-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)]">LANGUAGES</span><span className="text-[var(--text-primary)] text-right max-w-[180px]">{d.country?.languages?.join(', ')}</span></div>
|
||||||
{d.country?.currencies?.length > 0 && (
|
{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 && (
|
{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 */}
|
{/* LOCAL */}
|
||||||
{(d.local?.name || d.local?.state) && (
|
{(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>
|
<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.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-gray-500">STATE/PROVINCE</span><span className="text-white font-bold">{d.local.state}</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-gray-500">TYPE</span><span className="text-gray-300">{d.local.description}</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 && (
|
{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>
|
<span className="text-emerald-400 font-bold">>_ INTEL: </span>
|
||||||
{d.local.summary.length > 500 ? d.local.summary.substring(0, 500) + '...' : d.local.summary}
|
{d.local.summary.length > 500 ? d.local.summary.substring(0, 500) + '...' : d.local.summary}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sentinel-2 imagery now shown as map popup — see MaplibreViewer */}
|
||||||
</div>
|
</div>
|
||||||
) : d?.error ? (
|
) : 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>
|
<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> = {
|
const alertBorderMap: Record<string, string> = {
|
||||||
'pink': 'border-pink-500/30', 'red': 'border-red-500/30',
|
'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> = {
|
const alertBgMap: Record<string, string> = {
|
||||||
'pink': 'bg-pink-950/40', 'red': 'bg-red-950/40',
|
'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 ac = flight.alert_color || 'white';
|
||||||
const headerColor = alertColorMap[ac] || 'text-white';
|
const headerColor = alertColorMap[ac] || 'text-white';
|
||||||
const borderColor = alertBorderMap[ac] || 'border-gray-500/30';
|
const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30';
|
||||||
const bgColor = alertBgMap[ac] || 'bg-gray-900/40';
|
const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: 50, opacity: 0 }}
|
initial={{ y: 50, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.4 }}
|
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`}>
|
<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`}>
|
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
|
||||||
⚠ TRACKED AIRCRAFT — {flight.alert_category || "ALERT"}
|
⚠ TRACKED AIRCRAFT — {flight.alert_category || "ALERT"}
|
||||||
</h2>
|
</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>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3">
|
<div className="p-4 flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">OPERATOR</span>
|
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
|
||||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (
|
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (
|
||||||
<a
|
<a
|
||||||
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
|
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
|
||||||
@@ -273,7 +309,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
</div>
|
</div>
|
||||||
{/* Owner/Operator Wikipedia photo */}
|
{/* Owner/Operator Wikipedia photo */}
|
||||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (
|
{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
|
<WikiImage
|
||||||
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
|
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
|
||||||
label={flight.alert_operator}
|
label={flight.alert_operator}
|
||||||
@@ -284,12 +320,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
)}
|
)}
|
||||||
{/* Aircraft model Wikipedia photo */}
|
{/* Aircraft model Wikipedia photo */}
|
||||||
{aircraftImgUrl && (
|
{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">
|
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
|
||||||
<img
|
<img
|
||||||
src={aircraftImgUrl}
|
src={aircraftImgUrl}
|
||||||
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
|
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>
|
</a>
|
||||||
{aircraftWikiUrl && (
|
{aircraftWikiUrl && (
|
||||||
@@ -300,65 +336,65 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">CATEGORY</span>
|
<span className="text-[var(--text-muted)] text-[10px]">CATEGORY</span>
|
||||||
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_category || "N/A"}</span>
|
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_category || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">AIRCRAFT</span>
|
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT</span>
|
||||||
<span className="text-white text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
|
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
||||||
<span className="text-white text-xs font-bold">{flight.registration || "N/A"}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
{flight.alert_tag1 && (
|
{flight.alert_tag1 && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">INTEL TAG</span>
|
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAG</span>
|
||||||
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_tag1}</span>
|
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_tag1}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{flight.alert_tag2 && (
|
{flight.alert_tag2 && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">SECONDARY</span>
|
<span className="text-[var(--text-muted)] text-[10px]">SECONDARY</span>
|
||||||
<span className="text-white text-xs font-bold">{flight.alert_tag2}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_tag2}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{flight.alert_tag3 && (
|
{flight.alert_tag3 && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">DETAIL</span>
|
<span className="text-[var(--text-muted)] text-[10px]">DETAIL</span>
|
||||||
<span className="text-gray-400 text-xs">{flight.alert_tag3}</span>
|
<span className="text-[var(--text-secondary)] text-xs">{flight.alert_tag3}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
|
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
|
||||||
<span className="text-white text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
|
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
|
||||||
<span className="text-white text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">HEADING</span>
|
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
|
||||||
<span className="text-white text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
||||||
</div>
|
</div>
|
||||||
{flight.squawk && (
|
{flight.squawk && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">SQUAWK</span>
|
<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-white'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</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>
|
||||||
)}
|
)}
|
||||||
{flight.alert_link && (
|
{flight.alert_link && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">REFERENCE</span>
|
<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`}>
|
<a href={flight.alert_link} target="_blank" rel="noreferrer" className={`text-xs font-bold underline ${headerColor} hover:opacity-80`}>
|
||||||
View Intel Source
|
View Intel Source
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{flight.icao24 && (
|
{flight.icao24 && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">FLIGHT RECORD</span>
|
<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`}>
|
<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
|
View History Log
|
||||||
</a>
|
</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`}>
|
<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"}
|
{selectedEntity.type === 'military_flight' ? "MILITARY BOGEY INTERCEPT" : selectedEntity.type === 'private_flight' ? "PRIVATE TRANSPONDER" : selectedEntity.type === 'private_jet' ? "PRIVATE JET TRANSPONDER" : "COMMERCIAL TRANSPONDER"}
|
||||||
</h2>
|
</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>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3">
|
<div className="p-4 flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">OPERATOR</span>
|
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
|
||||||
<span className="text-white text-xs font-bold">{airline}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{airline}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
|
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
||||||
<span className="text-white text-xs font-bold">{flight.registration || "N/A"}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">AIRCRAFT MODEL</span>
|
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT MODEL</span>
|
||||||
<span className="text-white text-xs font-bold">{flight.model || "UNKNOWN"}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Aircraft photo + Wikipedia link */}
|
{/* Aircraft photo + Wikipedia link */}
|
||||||
{(aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
|
{(aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
|
||||||
<div className="border-b border-gray-800 pb-3">
|
<div className="border-b border-[var(--border-primary)] pb-3">
|
||||||
{aircraftImgLoading && (
|
{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 && (
|
{aircraftImgUrl && (
|
||||||
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
|
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
|
||||||
<img
|
<img
|
||||||
src={aircraftImgUrl}
|
src={aircraftImgUrl}
|
||||||
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
|
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' }}
|
style={{ imageRendering: 'auto' }}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
@@ -457,31 +493,31 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
|
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
|
||||||
<span className="text-white text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
|
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
|
||||||
<span className="text-white text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">HEADING</span>
|
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
|
||||||
<span className="text-white text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
||||||
</div>
|
</div>
|
||||||
{flight.squawk && (
|
{flight.squawk && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">SQUAWK</span>
|
<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-white'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</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>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">ROUTE</span>
|
<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>
|
<span className="text-cyan-400 text-xs font-bold">{flight.origin_name !== "UNKNOWN" ? `[${flight.origin_name}] → [${flight.dest_name}]` : "UNKNOWN"}</span>
|
||||||
</div>
|
</div>
|
||||||
{flight.icao24 && (
|
{flight.icao24 && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">FLIGHT RECORD</span>
|
<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">
|
<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
|
View History Log
|
||||||
</a>
|
</a>
|
||||||
@@ -514,7 +550,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
'military_vessel': 'text-yellow-400',
|
'military_vessel': 'text-yellow-400',
|
||||||
'carrier': 'text-orange-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> = {
|
const headerTitleMap: Record<string, string> = {
|
||||||
'tanker': 'AIS TANKER INTERCEPT',
|
'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`}>
|
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
|
||||||
{headerTitle}
|
{headerTitle}
|
||||||
</h2>
|
</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>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3">
|
<div className="p-4 flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">VESSEL NAME</span>
|
<span className="text-[var(--text-muted)] text-[10px]">VESSEL NAME</span>
|
||||||
<span className="text-white text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">VESSEL TYPE</span>
|
<span className="text-[var(--text-muted)] text-[10px]">VESSEL TYPE</span>
|
||||||
<span className={`text-xs font-bold ${headerColor}`}>{typeLabel}</span>
|
<span className={`text-xs font-bold ${headerColor}`}>{typeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">FLAG STATE</span>
|
<span className="text-[var(--text-muted)] text-[10px]">FLAG STATE</span>
|
||||||
<span className="text-white text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
{ship.callsign && (
|
{ship.callsign && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">CALLSIGN</span>
|
<span className="text-[var(--text-muted)] text-[10px]">CALLSIGN</span>
|
||||||
<span className="text-white text-xs font-bold">{ship.callsign}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.callsign}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ship.imo > 0 && (
|
{ship.imo > 0 && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">IMO NUMBER</span>
|
<span className="text-[var(--text-muted)] text-[10px]">IMO NUMBER</span>
|
||||||
<span className="text-white text-xs font-bold">{ship.imo}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.imo}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">DESTINATION</span>
|
<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>
|
<span className={`text-xs font-bold ${ship.destination && ship.destination !== 'UNKNOWN' ? 'text-cyan-400' : 'text-orange-400'}`}>{ship.destination || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">SPEED (SOG)</span>
|
<span className="text-[var(--text-muted)] text-[10px]">SPEED (SOG)</span>
|
||||||
<span className="text-white text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${ship.sog || 0} kts`}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${ship.sog || 0} kts`}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">COURSE (COG)</span>
|
<span className="text-[var(--text-muted)] text-[10px]">COURSE (COG)</span>
|
||||||
<span className="text-white text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${Math.round(ship.cog || 0)}°`}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${Math.round(ship.cog || 0)}°`}</span>
|
||||||
</div>
|
</div>
|
||||||
{ship.mmsi && (
|
{ship.mmsi && (
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">VESSEL RECORD</span>
|
<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">
|
<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
|
View on MarineTraffic
|
||||||
</a>
|
</a>
|
||||||
@@ -587,7 +623,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
)}
|
)}
|
||||||
{/* Ship/Carrier Wikipedia photo */}
|
{/* Ship/Carrier Wikipedia photo */}
|
||||||
{(ship.wiki || VESSEL_TYPE_WIKI[ship.type]) && (
|
{(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
|
<WikiImage
|
||||||
wikiUrl={ship.wiki || VESSEL_TYPE_WIKI[ship.type]}
|
wikiUrl={ship.wiki || VESSEL_TYPE_WIKI[ship.type]}
|
||||||
label={ship.type === 'carrier' ? ship.name : typeLabel}
|
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">
|
<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
|
<AlertTriangle size={14} className="text-orange-400" /> MILITARY INCIDENT CLUSTER
|
||||||
</h2>
|
</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>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3">
|
<div className="p-4 flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">LOCATION</span>
|
<span className="text-[var(--text-muted)] text-[10px]">LOCATION</span>
|
||||||
<span className="text-white text-xs font-bold text-right ml-4">{props.name || 'UNKNOWN REGION'}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{props.name || 'UNKNOWN REGION'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">ARTICLE COUNT</span>
|
<span className="text-[var(--text-muted)] text-[10px]">ARTICLE COUNT</span>
|
||||||
<span className="text-orange-400 text-xs font-bold">{props.count || 1}</span>
|
<span className="text-orange-400 text-xs font-bold">{props.count || 1}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
<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
|
<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.' }}
|
dangerouslySetInnerHTML={{ __html: props.html || 'No articles available.' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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
|
<AlertTriangle size={14} className="text-yellow-400" /> REGIONAL TACTICAL EVENT
|
||||||
</h2>
|
</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>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3">
|
<div className="p-4 flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">REGION</span>
|
<span className="text-[var(--text-muted)] text-[10px]">REGION</span>
|
||||||
<span className="text-white text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
|
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">DESCRIPTION</span>
|
<span className="text-[var(--text-muted)] text-[10px]">DESCRIPTION</span>
|
||||||
<span className="text-yellow-400 text-xs font-bold leading-tight">{item.title}</span>
|
<span className="text-yellow-400 text-xs font-bold leading-tight">{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2 mt-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2 mt-2">
|
||||||
<span className="text-gray-500 text-[10px]">REPORTED TIME</span>
|
<span className="text-[var(--text-muted)] text-[10px]">REPORTED TIME</span>
|
||||||
<span className="text-white text-xs font-bold">{item.timestamp || 'UNKNOWN'}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{item.timestamp || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.link && (
|
{item.link && (
|
||||||
<div className="flex justify-between items-center pb-2 mt-2">
|
<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">
|
<a href={item.link} target="_blank" rel="noreferrer" className="text-yellow-400 hover:text-yellow-300 text-xs font-bold underline">
|
||||||
View Liveuamap Report
|
View Liveuamap Report
|
||||||
</a>
|
</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">
|
<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
|
<AlertTriangle size={14} className="text-red-400" /> THREAT INTERCEPT
|
||||||
</h2>
|
</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>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3">
|
<div className="p-4 flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">SOURCE</span>
|
<span className="text-[var(--text-muted)] text-[10px]">SOURCE</span>
|
||||||
<span className="text-white text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
|
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">HEADLINE</span>
|
<span className="text-[var(--text-muted)] text-[10px]">HEADLINE</span>
|
||||||
<span className="text-red-400 text-xs font-bold leading-tight">{item.title}</span>
|
<span className="text-red-400 text-xs font-bold leading-tight">{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.machine_assessment && (
|
{item.machine_assessment && (
|
||||||
@@ -721,7 +757,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
)}
|
)}
|
||||||
{item.link && (
|
{item.link && (
|
||||||
<div className="flex justify-between items-center pb-2 mt-2">
|
<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">
|
<a href={item.link} target="_blank" rel="noreferrer" className="text-red-400 hover:text-red-300 text-xs font-bold underline">
|
||||||
View Source Article
|
View Source Article
|
||||||
</a>
|
</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">
|
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||||
AERONAUTICAL HUB
|
AERONAUTICAL HUB
|
||||||
</h2>
|
</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>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3">
|
<div className="p-4 flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">FACILITY NAME</span>
|
<span className="text-[var(--text-muted)] text-[10px]">FACILITY NAME</span>
|
||||||
<span className="text-white text-[10px] font-bold text-right ml-4 break-words">{apt.name}</span>
|
<span className="text-[var(--text-primary)] text-[10px] font-bold text-right ml-4 break-words">{apt.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">COORDINATES</span>
|
<span className="text-[var(--text-muted)] text-[10px]">COORDINATES</span>
|
||||||
<span className="text-white text-xs font-bold">{apt.lat.toFixed(4)}, {apt.lng.toFixed(4)}</span>
|
<span className="text-[var(--text-primary)] text-xs font-bold">{apt.lat.toFixed(4)}, {apt.lng.toFixed(4)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||||
<span className="text-gray-500 text-[10px]">STATUS</span>
|
<span className="text-[var(--text-muted)] text-[10px]">STATUS</span>
|
||||||
<span className="text-green-400 animate-pulse text-xs font-bold">OPERATIONAL</span>
|
<span className="text-green-400 animate-pulse text-xs font-bold">OPERATIONAL</span>
|
||||||
</div>
|
</div>
|
||||||
</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'
|
? 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'}
|
: 'OPTIC INTERCEPT'}
|
||||||
</h2>
|
</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>
|
||||||
<div className="relative w-full h-48 bg-black flex items-center justify-center p-1">
|
<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 (
|
if (mt === 'hls') return (
|
||||||
<video
|
<HlsVideo
|
||||||
src={url}
|
url={url}
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
className="w-full h-full object-cover border border-cyan-900/50 rounded-sm filter contrast-125 saturate-50"
|
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 }}
|
initial={{ y: 50, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
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
|
<div
|
||||||
className="p-3 border-b border-cyan-500/20 bg-cyan-950/20 relative overflow-hidden cursor-pointer hover:bg-cyan-900/30 transition-colors"
|
className="p-3 border-b border-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">
|
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||||
<AlertTriangle size={14} /> GLOBAL THREAT INTERCEPT
|
<AlertTriangle size={14} /> GLOBAL THREAT INTERCEPT
|
||||||
</h2>
|
</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} />}
|
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -938,14 +971,14 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
transition={{ delay: 0.1 + (idx * 0.05) }}
|
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`}
|
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">
|
<span className="font-bold flex items-center gap-1 text-cyan-600">
|
||||||
>_ {item.source}
|
>_ {item.source}
|
||||||
</span>
|
</span>
|
||||||
<span>[{item.published ? formatTime(item.published) : ''}]</span>
|
<span>[{item.published ? formatTime(item.published) : ''}]</span>
|
||||||
</div>
|
</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}
|
{item.title}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -963,12 +996,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{item.cluster_count > 1 && (
|
{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]`}
|
{isExpanded ? '[- COLLAPSE]' : `[+${item.cluster_count - 1} SOURCES]`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{item.coords && (
|
{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)}
|
{item.coords[0].toFixed(2)}, {item.coords[1].toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -985,7 +1018,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
>
|
>
|
||||||
{item.articles.slice(1).map((subItem: any, subIdx: number) => (
|
{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 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>>_ {subItem.source}</span>
|
||||||
<span className={
|
<span className={
|
||||||
subItem.risk_score >= 9 ? 'text-red-400' :
|
subItem.risk_score >= 9 ? 'text-red-400' :
|
||||||
@@ -994,7 +1027,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
'text-green-400'
|
'text-green-400'
|
||||||
}>LVL: {subItem.risk_score}/10</span>
|
}>LVL: {subItem.risk_score}/10</span>
|
||||||
</div>
|
</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}
|
{subItem.title}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
|
import { API_BASE } from "@/lib/api";
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { RadioReceiver, Activity, Play, Square, FastForward, ChevronDown, ChevronUp } from 'lucide-react';
|
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 [isMinimized, setIsMinimized] = useState(true);
|
||||||
const [feeds, setFeeds] = useState<any[]>([]);
|
const [feeds, setFeeds] = useState<any[]>([]);
|
||||||
const [activeFeed, setActiveFeed] = useState<any | null>(null);
|
const [activeFeed, setActiveFeed] = useState<any | null>(null);
|
||||||
@@ -18,7 +19,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFeeds = async () => {
|
const fetchFeeds = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("http://localhost:8000/api/radio/top");
|
const res = await fetch(`${API_BASE}/api/radio/top`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setFeeds(json);
|
setFeeds(json);
|
||||||
@@ -47,12 +48,12 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
|||||||
category: 'SIGINT'
|
category: 'SIGINT'
|
||||||
}, ...prev]);
|
}, ...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) {
|
if (res.ok) {
|
||||||
const system = await res.json();
|
const system = await res.json();
|
||||||
if (system && system.shortName) {
|
if (system && system.shortName) {
|
||||||
// Valid OpenMHZ system found! Fetch recent calls
|
// 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) {
|
if (callRes.ok) {
|
||||||
const calls = await callRes.json();
|
const calls = await callRes.json();
|
||||||
if (calls && calls.length > 0) {
|
if (calls && calls.length > 0) {
|
||||||
@@ -189,14 +190,14 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
|||||||
|
|
||||||
if (scanLoc) {
|
if (scanLoc) {
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const systems = await res.json();
|
const systems = await res.json();
|
||||||
|
|
||||||
// Try to find a system with an active unplayed burst
|
// Try to find a system with an active unplayed burst
|
||||||
for (const system of systems) {
|
for (const system of systems) {
|
||||||
if (system && system.shortName) {
|
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) {
|
if (callRes.ok) {
|
||||||
const calls = await callRes.json();
|
const calls = await callRes.json();
|
||||||
if (calls && calls.length > 0) {
|
if (calls && calls.length > 0) {
|
||||||
@@ -248,7 +249,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
|||||||
initial={{ opacity: 0, x: 50 }}
|
initial={{ opacity: 0, x: 50 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 1, delay: 0.2 }}
|
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
|
<div
|
||||||
className="flex items-center justify-between p-3 border-b border-cyan-900/50 cursor-pointer bg-cyan-950/20 hover:bg-cyan-900/30 transition-colors"
|
className="flex items-center justify-between p-3 border-b border-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"
|
className="flex flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Audio Player Controls */}
|
{/* 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 items-center justify-between mb-3">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-cyan-300 font-mono tracking-wide">
|
<span className="text-xs text-cyan-300 font-mono tracking-wide">
|
||||||
{activeFeed ? activeFeed.name : "NO SIGNAL"}
|
{activeFeed ? activeFeed.name : "NO SIGNAL"}
|
||||||
</span>
|
</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..."}
|
{activeFeed ? `LOCATION: ${activeFeed.location.toUpperCase()}` : "AWAITING TUNING..."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,6 +347,36 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Feed List */}
|
||||||
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
||||||
{feeds.length === 0 ? (
|
{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`}
|
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">
|
<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}
|
{feed.name}
|
||||||
</span>
|
</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}
|
{feed.location} | {feed.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -370,7 +401,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
|||||||
<Activity size={10} />
|
<Activity size={10} />
|
||||||
{feed.listeners.toLocaleString()}
|
{feed.listeners.toLocaleString()}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
|
|||||||
{/* Unit toggle */}
|
{/* Unit toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setUnit(u => u === "mi" ? "km" : "mi")}
|
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)"}`}
|
title={`Switch to ${unit === "mi" ? "Metric (km)" : "Imperial (mi)"}`}
|
||||||
>
|
>
|
||||||
{unit === "mi" ? "MI" : "KM"}
|
{unit === "mi" ? "MI" : "KM"}
|
||||||
@@ -147,7 +147,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
|
|||||||
onClick={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
|
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-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)"}
|
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 && (
|
{measureMode && measurePoints && measurePoints.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={onClearMeasure}
|
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"
|
title="Clear all waypoints"
|
||||||
>
|
>
|
||||||
<Trash2 size={10} />
|
<Trash2 size={10} />
|
||||||
@@ -172,7 +172,7 @@ function ScaleBar({ zoom, latitude, measureMode, measurePoints, onToggleMeasure,
|
|||||||
{segmentDistances.map((d, i) => (
|
{segmentDistances.map((d, i) => (
|
||||||
<span key={i} className={`text-[9px] font-mono tracking-wider px-1.5 py-0.5 rounded border ${d.startsWith("Σ")
|
<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-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}
|
{d}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { API_BASE } from "@/lib/api";
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
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 {
|
interface ApiEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,7 +15,7 @@ interface ApiEntry {
|
|||||||
has_key: boolean;
|
has_key: boolean;
|
||||||
env_key: string | null;
|
env_key: string | null;
|
||||||
value_obfuscated: string | null;
|
value_obfuscated: string | null;
|
||||||
value_plain: string | null;
|
is_set: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category colors for the tactical UI
|
// 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 SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||||
const [apis, setApis] = useState<ApiEntry[]>([]);
|
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 [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -41,7 +40,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
|
|
||||||
const fetchKeys = useCallback(async () => {
|
const fetchKeys = useCallback(async () => {
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setApis(data);
|
setApis(data);
|
||||||
@@ -55,35 +54,16 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
if (isOpen) fetchKeys();
|
if (isOpen) fetchKeys();
|
||||||
}, [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) => {
|
const startEditing = (api: ApiEntry) => {
|
||||||
setEditingId(api.id);
|
setEditingId(api.id);
|
||||||
setEditValue(api.value_plain || "");
|
setEditValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveKey = async (api: ApiEntry) => {
|
const saveKey = async (api: ApiEntry) => {
|
||||||
if (!api.env_key) return;
|
if (!api.env_key) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("http://localhost:8000/api/settings/api-keys", {
|
const res = await fetch(`${API_BASE}/api/settings/api-keys`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
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 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: -300 }}
|
exit={{ opacity: 0, x: -300 }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 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 */}
|
{/* 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="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">
|
<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" />
|
<Settings size={16} className="text-cyan-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">SYSTEM CONFIG</h2>
|
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
|
||||||
<span className="text-[9px] text-gray-500 font-mono tracking-widest">API KEY REGISTRY</span>
|
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">API KEY REGISTRY</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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} />
|
<X size={14} />
|
||||||
</button>
|
</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="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
<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.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,21 +152,21 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
const isExpanded = expandedCategories.has(category);
|
const isExpanded = expandedCategories.has(category);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Category Header */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCategory(category)}
|
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">
|
<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}`}>
|
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
||||||
{category.toUpperCase()}
|
{category.toUpperCase()}
|
||||||
</span>
|
</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'}
|
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
|
|
||||||
{/* APIs in Category */}
|
{/* APIs in Category */}
|
||||||
@@ -199,20 +179,26 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
{categoryApis.map((api) => (
|
{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 */}
|
{/* API Name + Status */}
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{api.required && <Key size={10} className="text-yellow-500" />}
|
{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>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{api.has_key ? (
|
{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">
|
api.is_set ? (
|
||||||
KEY 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">
|
||||||
</span>
|
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
|
PUBLIC
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -221,7 +207,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
href={api.url}
|
href={api.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ExternalLink size={10} />
|
<ExternalLink size={10} />
|
||||||
@@ -231,7 +217,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* 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}
|
{api.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -259,7 +245,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingId(null)}
|
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
|
ESC
|
||||||
</button>
|
</button>
|
||||||
@@ -268,37 +254,13 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
/* Display Mode */
|
/* Display Mode */
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div
|
<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)}
|
onClick={() => startEditing(api)}
|
||||||
>
|
>
|
||||||
<span className={revealedKeys.has(api.id) ? "text-cyan-300" : "text-gray-500 tracking-wider"}>
|
<span className="text-[var(--text-muted)] tracking-wider">
|
||||||
{revealedKeys.has(api.id) ? api.value_plain : api.value_obfuscated}
|
{api.is_set ? api.value_obfuscated : "Click to set key..."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Eye Toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => toggleReveal(api.id)}
|
|
||||||
className={`w-7 h-7 rounded flex items-center justify-center border transition-all ${revealedKeys.has(api.id)
|
|
||||||
? "border-cyan-500/40 text-cyan-400 bg-cyan-950/30"
|
|
||||||
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
|
||||||
}`}
|
|
||||||
title={revealedKeys.has(api.id) ? "Hide key" : "Reveal key"}
|
|
||||||
>
|
|
||||||
{revealedKeys.has(api.id) ? <EyeOff size={12} /> : <Eye size={12} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Copy */}
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(api.id, api.value_plain || "")}
|
|
||||||
className={`w-7 h-7 rounded flex items-center justify-center border transition-all ${copiedId === api.id
|
|
||||||
? "border-green-500/40 text-green-400 bg-green-950/30"
|
|
||||||
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
|
||||||
}`}
|
|
||||||
title="Copy to clipboard"
|
|
||||||
>
|
|
||||||
{copiedId === api.id ? <Check size={12} /> : <Copy size={12} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -314,8 +276,8 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-gray-800/80">
|
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
||||||
<div className="flex items-center justify-between text-[9px] text-gray-600 font-mono">
|
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
|
||||||
<span>{apis.length} REGISTERED APIs</span>
|
<span>{apis.length} REGISTERED APIs</span>
|
||||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,14 +49,14 @@ export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent =
|
|||||||
return (
|
return (
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
{loading && (
|
{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 && (
|
{imgUrl && (
|
||||||
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" className="block">
|
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||||
<img
|
<img
|
||||||
src={imgUrl}
|
src={imgUrl}
|
||||||
alt={label || title.replace(/_/g, ' ')}
|
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>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
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 [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
|
// Compute ship category counts
|
||||||
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0;
|
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: "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: "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: "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 },
|
{ 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 */}
|
{/* Header */}
|
||||||
<div className="mb-6 pointer-events-auto">
|
<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-[var(--text-secondary)] 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-muted)] font-mono tracking-widest mb-4">KH11-4094 OPS-4168</div>
|
||||||
<div className="flex items-center gap-3">
|
<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 && (
|
{onSettingsClick && (
|
||||||
<button
|
<button
|
||||||
onClick={onSettingsClick}
|
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"
|
title="System Settings"
|
||||||
>
|
>
|
||||||
<Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" />
|
<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 && (
|
{onLegendClick && (
|
||||||
<button
|
<button
|
||||||
onClick={onLegendClick}
|
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"
|
title="Map Legend / Icon Key"
|
||||||
>
|
>
|
||||||
<BookOpen size={12} />
|
<BookOpen size={12} />
|
||||||
@@ -68,15 +106,15 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data Layers Box */}
|
{/* 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 */}
|
{/* Header / Toggle */}
|
||||||
<div
|
<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)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA LAYERS</span>
|
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DATA LAYERS</span>
|
||||||
<button className="text-gray-500 hover:text-white transition-colors">
|
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,31 +133,78 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
|||||||
const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
|
const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={idx} className="flex flex-col">
|
||||||
key={idx}
|
<div
|
||||||
className="flex items-start justify-between group cursor-pointer"
|
className="flex items-start justify-between group cursor-pointer"
|
||||||
onClick={() => setActiveLayers((prev: any) => ({ ...prev, [layer.id]: !active }))}
|
onClick={() => setActiveLayers((prev: any) => ({ ...prev, [layer.id]: !active }))}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className={`mt-1 ${active ? 'text-cyan-400' : 'text-gray-600 group-hover:text-gray-400'} transition-colors`}>
|
<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} />}
|
{(['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>
|
||||||
<div className="flex flex-col">
|
<div className="flex items-center gap-3">
|
||||||
<span className={`text-sm font-medium ${active ? 'text-white' : 'text-gray-400'} tracking-wide`}>{layer.name}</span>
|
{active && layer.count > 0 && (
|
||||||
<span className="text-[9px] text-gray-600 font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span>
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
{/* GIBS Imagery inline controls: time slider + play/pause + opacity */}
|
||||||
{active && layer.count > 0 && (
|
{active && layer.id === 'gibs_imagery' && gibsDate && setGibsDate && setGibsOpacity && (
|
||||||
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
|
<div className="ml-7 mt-2 flex flex-col gap-2" onClick={e => e.stopPropagation()}>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
<div className={`text-[9px] font-mono tracking-wider px-2 py-0.5 rounded-full border ${active
|
<button
|
||||||
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/30 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
|
onClick={() => setGibsPlaying(p => !p)}
|
||||||
: 'border-gray-800 text-gray-600 bg-transparent'
|
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"
|
||||||
}`}>
|
>
|
||||||
{active ? 'ON' : 'OFF'}
|
{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>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
|||||||
initial={{ opacity: 0, x: -50 }}
|
initial={{ opacity: 0, x: -50 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 1 }}
|
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 */}
|
{/* 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="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-gray-500/50"></div>
|
<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-gray-500/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="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}
|
REC {currentTime.date} {currentTime.time}
|
||||||
<br />
|
<br />
|
||||||
ORB: 47696 PASS: DESC-284
|
ORB: 47696 PASS: DESC-284
|
||||||
@@ -41,15 +41,15 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side controls box */}
|
{/* 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 */}
|
{/* Header / Toggle */}
|
||||||
<div
|
<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)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DISPLAY CONFIG</span>
|
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DISPLAY CONFIG</span>
|
||||||
<button className="text-gray-500 hover:text-white transition-colors">
|
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,14 +66,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
|||||||
|
|
||||||
{/* Bloom Toggle */}
|
{/* Bloom Toggle */}
|
||||||
<div
|
<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 })}
|
onClick={() => setEffects({ ...effects, bloom: !effects.bloom })}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-gray-600'}`}>✧</span>
|
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sharpen Slider */}
|
{/* 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>
|
<span className="text-xs font-mono tracking-widest text-cyan-400 font-bold">SHARPEN</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 mt-1">
|
<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-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 className="absolute left-[49%] top-1/2 -translate-y-1/2 w-2 h-2 bg-white rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,14 +96,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
|||||||
|
|
||||||
{/* HUD Dropdown */}
|
{/* HUD Dropdown */}
|
||||||
<div className="flex flex-col gap-2 relative">
|
<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="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>
|
<span className="text-xs font-mono tracking-widest">HUD</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border border-gray-800 rounded px-4 py-2 mt-1 bg-black/50">
|
<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-gray-500 font-mono">LAYOUT</span>
|
<span className="text-[10px] text-[var(--text-muted)] 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">
|
<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
|
Tactical
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 S H A D O W B R O K E R -- STARTUP
|
||||||
echo ===================================================
|
echo ===================================================
|
||||||
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
|
cd backend
|
||||||
if not exist "venv\" (
|
if not exist "venv\" (
|
||||||
echo Creating Python virtual environment...
|
echo [*] Creating Python virtual environment...
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo [!] ERROR: Failed to create virtual environment.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
)
|
)
|
||||||
call venv\Scripts\activate.bat
|
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 ..
|
cd ..
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo Installing frontend dependencies if needed...
|
echo [*] Setting up frontend...
|
||||||
cd frontend
|
cd frontend
|
||||||
if not exist "node_modules\" (
|
if not exist "node_modules\" (
|
||||||
echo Running npm install...
|
echo [*] Installing frontend dependencies...
|
||||||
call npm install
|
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.
|
||||||
echo Starting both services...
|
echo ===================================================
|
||||||
echo (Press Ctrl+C to stop the dashboard)
|
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.
|
echo.
|
||||||
|
|
||||||
:: Start the dev server which runs both NEXT and API via concurrently
|
|
||||||
call npm run dev
|
call npm run dev
|
||||||
|
|||||||
@@ -6,47 +6,75 @@ echo ""
|
|||||||
|
|
||||||
# Check for Node.js
|
# Check for Node.js
|
||||||
if ! command -v npm &> /dev/null; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "[*] Found Node.js $(node --version)"
|
||||||
|
|
||||||
# Check for Python
|
# Check for Python 3
|
||||||
if ! command -v python3 &> /dev/null; then
|
PYTHON_CMD=""
|
||||||
echo "[!] ERROR: python3 is not installed. Please install Python 3.10+ (https://python.org/)"
|
if command -v python3 &> /dev/null; then
|
||||||
exit 1
|
PYTHON_CMD="python3"
|
||||||
fi
|
elif command -v python &> /dev/null; then
|
||||||
|
PYTHON_CMD="python"
|
||||||
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
|
|
||||||
else
|
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
|
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
|
pip install -r requirements.txt
|
||||||
cd ..
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
echo "[*] Setting up Frontend Environment..."
|
echo "[!] ERROR: pip install failed. See errors above."
|
||||||
cd frontend
|
echo "[!] If you see Rust/cargo errors, your Python version may be too new."
|
||||||
if [ ! -d "node_modules" ]; then
|
echo "[!] Recommended: Python 3.10, 3.11, or 3.12."
|
||||||
echo "[*] Installing Frontend dependencies..."
|
exit 1
|
||||||
npm install
|
|
||||||
fi
|
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 "======================================================="
|
echo "======================================================="
|
||||||
echo " 🚀 Starting Services... "
|
echo " Starting services... "
|
||||||
echo " Dashboard will be available at: http://localhost:3000"
|
echo " Dashboard: http://localhost:3000 "
|
||||||
echo " Keep this window open! Note: Initial load takes ~10s "
|
echo " Keep this window open! Initial load takes ~10s. "
|
||||||
echo "======================================================="
|
echo "======================================================="
|
||||||
|
echo " (Press Ctrl+C to stop)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Start both services (npm run dev automatically calls the python backend on Mac/Linux if scripts are configured cross-platform)
|
|
||||||
npm run dev
|
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