mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-15 20:58:00 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0edc84c997 | |||
| 0519ed040b | |||
| 80fedc103a | |||
| 5cefd8f8d5 | |||
| 75537a8570 | |||
| bc13706311 | |||
| 3711c84ebe | |||
| 8e79c03d88 | |||
| 9419ed9883 |
@@ -40,10 +40,6 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5.0.0
|
uses: docker/metadata-action@v5.0.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
|
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
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
@@ -83,10 +79,6 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5.0.0
|
uses: docker/metadata-action@v5.0.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
|
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
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
|
|||||||
-30
@@ -64,33 +64,3 @@ 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
|
|
||||||
backend/nyc_sample.json
|
|
||||||
backend/nyc_full.json
|
|
||||||
backend/liveua_test.html
|
|
||||||
backend/out_liveua.json
|
|
||||||
frontend/server_logs*.txt
|
|
||||||
frontend/cctv.db
|
|
||||||
*.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
|
|
||||||
|
|||||||
@@ -7,131 +7,76 @@
|
|||||||
</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 via CelesTrak TLE data + SGP4 propagation (2,000+ active satellites, no API key required)
|
- **Orbital Tracking** — Real-time satellite positions from N2YO API
|
||||||
* **Mission-Type Classification** — Color-coded by mission: military recon (red), SAR (cyan), SIGINT (white), navigation (blue), early warning (magenta), commercial imaging (green), space station (gold)
|
- **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 with user-customizable feeds (up to 20 sources, configurable priority weights 1-5)
|
- **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
|
||||||
|
|
||||||
### 🔥 Environmental & Infrastructure Monitoring
|
|
||||||
|
|
||||||
* **NASA FIRMS Fire Hotspots (24h)** — 5,000+ global thermal anomalies from NOAA-20 VIIRS satellite, updated every cycle. Flame-shaped icons color-coded by fire radiative power (FRP): yellow (low), orange, red, dark red (intense). Clustered at low zoom with fire-shaped cluster markers.
|
|
||||||
* **Space Weather Badge** — Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1–G5). Data from SWPC planetary K-index 1-minute feed.
|
|
||||||
* **Internet Outage Monitoring** — Regional internet connectivity alerts from Georgia Tech IODA. Grey markers at affected regions with severity percentage. Uses only reliable datasources (BGP routing tables, active ping probing) — no telescope or interpolated data.
|
|
||||||
* **Data Center Mapping** — 2,000+ global data centers plotted from a curated dataset. Clustered purple markers with server-rack icons. Click for operator, location, and automatic internet outage cross-referencing by country.
|
|
||||||
|
|
||||||
### 🌐 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
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -147,7 +92,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
│ │ Map Render │ │ Intel │ │ Markets/Radio │ │
|
│ │ Map Render │ │ Intel │ │ Markets/Radio │ │
|
||||||
│ └──────┬──────┘ └────┬─────┘ └───────┬───────┘ │
|
│ └──────┬──────┘ └────┬─────┘ └───────┬───────┘ │
|
||||||
│ └────────────────┼──────────────────┘ │
|
│ └────────────────┼──────────────────┘ │
|
||||||
│ │ REST API (60s / 120s) │
|
│ │ REST API (15s / 60s) │
|
||||||
├──────────────────────────┼─────────────────────────────┤
|
├──────────────────────────┼─────────────────────────────┤
|
||||||
│ BACKEND (FastAPI) │
|
│ BACKEND (FastAPI) │
|
||||||
│ │ │
|
│ │ │
|
||||||
@@ -155,7 +100,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
│ │ Data Fetcher (Scheduler) │ │
|
│ │ Data Fetcher (Scheduler) │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ ┌──────────┬──────────┬──────────┬───────────┐ │ │
|
│ │ ┌──────────┬──────────┬──────────┬───────────┐ │ │
|
||||||
│ │ │ OpenSky │ adsb.lol │CelesTrak │ USGS │ │ │
|
│ │ │ OpenSky │ adsb.lol │ N2YO │ USGS │ │ │
|
||||||
│ │ │ Flights │ Military │ Sats │ Quakes │ │ │
|
│ │ │ Flights │ Military │ Sats │ Quakes │ │ │
|
||||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||||
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV │ │ │
|
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV │ │ │
|
||||||
@@ -163,9 +108,6 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||||
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
||||||
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
||||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
|
||||||
│ │ │ NASA │ NOAA │ IODA │ KiwiSDR │ │ │
|
|
||||||
│ │ │ FIRMS │ Space Wx│ Outages │ Radios │ │ │
|
|
||||||
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │
|
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
└────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────┘
|
||||||
@@ -180,7 +122,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
| [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** |
|
||||||
| [CelesTrak](https://celestrak.org) | Satellite orbital positions (TLE + SGP4) | ~60s | No |
|
| [N2YO](https://www.n2yo.com) | Satellite orbital positions | ~60s | **Yes** |
|
||||||
| [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 |
|
||||||
@@ -191,61 +133,53 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
|||||||
| [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 |
|
|
||||||
| [NASA FIRMS](https://firms.modaps.eosdis.nasa.gov) | NOAA-20 VIIRS fire/thermal hotspots | ~120s | No |
|
|
||||||
| [NOAA SWPC](https://services.swpc.noaa.gov) | Space weather Kp index & solar events | ~120s | No |
|
|
||||||
| [IODA (Georgia Tech)](https://ioda.inetintel.cc.gatech.edu) | Regional internet outage alerts | ~120s | No |
|
|
||||||
| [DC Map (GitHub)](https://github.com/Ringmast4r/Data-Center-Map---Global) | Global data center locations | Static (cached 7d) | 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)
|
### 🐳 Docker Setup (Recommended for Self-Hosting)
|
||||||
|
|
||||||
The repo includes a `docker-compose.yml` that builds both images locally.
|
You can run the dashboard easily using the pre-built Docker images hosted on GitHub Container Registry (GHCR).
|
||||||
|
|
||||||
```bash
|
1. Create a `docker-compose.yml` file:
|
||||||
git clone https://github.com/BigBodyCobain/Shadowbroker.git
|
|
||||||
cd Shadowbroker
|
```yaml
|
||||||
# Add your API keys in a repo-root .env file (optional — see Environment Variables below)
|
version: '3.8'
|
||||||
./compose.sh up -d
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: ghcr.io/<your-username>/live-risk-dashboard-backend:main
|
||||||
|
container_name: shadowbroker-backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- AISSTREAM_API_KEY=${AISSTREAM_API_KEY}
|
||||||
|
- N2YO_API_KEY=${N2YO_API_KEY}
|
||||||
|
# Add other required environment variables here
|
||||||
|
volumes:
|
||||||
|
- backend_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: ghcr.io/<your-username>/live-risk-dashboard-frontend:main
|
||||||
|
container_name: shadowbroker-frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_data:
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000` to view the dashboard.
|
1. Create a `.env` file in the same directory with your API keys.
|
||||||
|
2. Run `docker-compose up -d`.
|
||||||
> **Deploying publicly or on a LAN?** The frontend **auto-detects** the
|
3. Access the dashboard at `http://localhost:3000`.
|
||||||
> 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -254,7 +188,7 @@ Depending on your local Podman configuration, `podman compose` may still delegat
|
|||||||
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 latest `.zip` file from the release.
|
2. Download the `ShadowBroker_v0.2.zip` file.
|
||||||
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`.
|
||||||
@@ -268,10 +202,9 @@ If you want to modify the code or run from source:
|
|||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
* **Node.js** 18+ and **npm** — [nodejs.org](https://nodejs.org/)
|
- **Node.js** 18+ and **npm**
|
||||||
* **Python** 3.10, 3.11, or 3.12 with `pip` — [python.org](https://www.python.org/downloads/) (**check "Add to PATH"** during install)
|
- **Python** 3.10+ with `pip`
|
||||||
* ⚠️ Python 3.13+ may have compatibility issues with some dependencies. **3.11 or 3.12 is recommended.**
|
- API keys for: `aisstream.io`, `n2yo.com` (and optionally `opensky-network.org`, `lta.gov.sg`)
|
||||||
* API keys for: `aisstream.io` (required), and optionally `opensky-network.org` (OAuth2), `lta.gov.sg`
|
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -285,12 +218,13 @@ 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 # includes pystac-client for Sentinel-2
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Create .env with your API keys
|
# Create .env with your API keys
|
||||||
echo "AIS_API_KEY=your_aisstream_key" >> .env
|
echo "AISSTREAM_API_KEY=your_key_here" >> .env
|
||||||
echo "OPENSKY_CLIENT_ID=your_opensky_client_id" >> .env
|
echo "N2YO_API_KEY=your_key_here" >> .env
|
||||||
echo "OPENSKY_CLIENT_SECRET=your_opensky_secret" >> .env
|
echo "OPENSKY_USERNAME=your_user" >> .env
|
||||||
|
echo "OPENSKY_PASSWORD=your_pass" >> .env
|
||||||
|
|
||||||
# Frontend setup
|
# Frontend setup
|
||||||
cd ../frontend
|
cd ../frontend
|
||||||
@@ -306,8 +240,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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -331,12 +265,6 @@ 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 |
|
|
||||||
| Fire Hotspots (24h) | ❌ OFF | NASA FIRMS VIIRS thermal anomalies |
|
|
||||||
| Internet Outages | ❌ OFF | IODA regional connectivity alerts |
|
|
||||||
| Data Centers | ❌ OFF | Global data center locations (2,000+) |
|
|
||||||
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -345,15 +273,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
|
||||||
* **Imperative Map Updates** — High-volume layers (flights, satellites, fires) bypass React reconciliation via direct `setData()` calls
|
- **Clustered Rendering** — Ships, CCTV, and earthquakes use MapLibre clustering to reduce feature count
|
||||||
* **Clustered Rendering** — Ships, CCTV, earthquakes, and data centers 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; 2s debounce on dense layers (satellites, fires)
|
- **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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -365,8 +292,6 @@ live-risk-dashboard/
|
|||||||
│ ├── main.py # FastAPI app, middleware, API routes
|
│ ├── main.py # FastAPI app, middleware, API routes
|
||||||
│ ├── carrier_cache.json # Persisted carrier OSINT positions
|
│ ├── carrier_cache.json # Persisted carrier OSINT positions
|
||||||
│ ├── cctv.db # SQLite CCTV camera database
|
│ ├── cctv.db # SQLite CCTV camera database
|
||||||
│ ├── config/
|
|
||||||
│ │ └── news_feeds.json # User-customizable RSS feed list (persists across restarts)
|
|
||||||
│ └── services/
|
│ └── services/
|
||||||
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
|
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
|
||||||
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
||||||
@@ -375,11 +300,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
|
||||||
│ └── news_feed_config.py # RSS feed config manager (add/remove/weight feeds)
|
|
||||||
│
|
│
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
@@ -396,8 +318,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 (API Keys + News Feed manager)
|
|
||||||
│ │ ├── ScaleBar.tsx # Map scale indicator
|
│ │ ├── ScaleBar.tsx # Map scale indicator
|
||||||
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
||||||
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
|
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
|
||||||
@@ -408,29 +329,19 @@ live-risk-dashboard/
|
|||||||
|
|
||||||
## 🔑 Environment Variables
|
## 🔑 Environment Variables
|
||||||
|
|
||||||
### Backend (`backend/.env`)
|
Create a `.env` file in the `backend/` directory:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Required
|
# Required
|
||||||
AIS_API_KEY=your_aisstream_key # Maritime vessel tracking (aisstream.io)
|
AISSTREAM_API_KEY=your_aisstream_key # Maritime vessel tracking
|
||||||
|
N2YO_API_KEY=your_n2yo_key # Satellite position data
|
||||||
|
|
||||||
# Optional (enhances data quality)
|
# Optional (enhances data quality)
|
||||||
OPENSKY_CLIENT_ID=your_opensky_client_id # OAuth2 — higher rate limits for flight data
|
OPENSKY_CLIENT_ID=your_opensky_client_id # Higher rate limits for flight data
|
||||||
OPENSKY_CLIENT_SECRET=your_opensky_secret # OAuth2 — paired with Client ID above
|
OPENSKY_CLIENT_SECRET=your_opensky_secret
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ba57965389036194d6dd60e6de33d2e1e1bbf20b
|
||||||
@@ -5,12 +5,6 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.coverage
|
.coverage
|
||||||
cctv.db
|
cctv.db
|
||||||
|
*.json
|
||||||
*.txt
|
*.txt
|
||||||
!requirements.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,13 +2,6 @@ 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
|
||||||
@@ -16,9 +9,6 @@ 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
|
# Create a non-root user for security
|
||||||
RUN adduser --system --uid 1001 backenduser \
|
RUN adduser --system --uid 1001 backenduser \
|
||||||
&& chown -R backenduser /app
|
&& chown -R backenduser /app
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
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] || process.env.AIS_API_KEY;
|
const API_KEY = args[0] || '75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb';
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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}")
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"feeds": [
|
|
||||||
{ "name": "NPR", "url": "https://feeds.npr.org/1004/rss.xml", "weight": 4 },
|
|
||||||
{ "name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "weight": 3 },
|
|
||||||
{ "name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2 },
|
|
||||||
{ "name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1 },
|
|
||||||
{ "name": "GDACS", "url": "https://www.gdacs.org/xml/rss.xml", "weight": 5 },
|
|
||||||
{ "name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3 },
|
|
||||||
{ "name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3 },
|
|
||||||
{ "name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
5c3b1c768973ca54e9a1befee8dc075f38e8cc56
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2b64633521ffb6f06da36e19f5c8eb86979e2187
|
||||||
File diff suppressed because one or more lines are too long
+6
-100
@@ -1,44 +1,16 @@
|
|||||||
from fastapi import FastAPI, Request, Response
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data, source_timestamps
|
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data
|
||||||
from services.ais_stream import start_ais_stream, stop_ais_stream
|
from services.ais_stream import start_ais_stream, stop_ais_stream
|
||||||
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
import json as json_mod
|
import json as json_mod
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
def _build_cors_origins():
|
|
||||||
"""Build a CORS origins whitelist: localhost + LAN IPs + env overrides.
|
|
||||||
Falls back to wildcard only if auto-detection fails entirely."""
|
|
||||||
origins = [
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://127.0.0.1:3000",
|
|
||||||
"http://localhost:8000",
|
|
||||||
"http://127.0.0.1:8000",
|
|
||||||
]
|
|
||||||
# Add this machine's LAN IPs (covers common home/office setups)
|
|
||||||
try:
|
|
||||||
hostname = socket.gethostname()
|
|
||||||
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
|
||||||
ip = info[4][0]
|
|
||||||
if ip not in ("127.0.0.1", "0.0.0.0"):
|
|
||||||
origins.append(f"http://{ip}:3000")
|
|
||||||
origins.append(f"http://{ip}:8000")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Allow user override via CORS_ORIGINS env var (comma-separated)
|
|
||||||
extra = os.environ.get("CORS_ORIGINS", "")
|
|
||||||
if extra:
|
|
||||||
origins.extend([o.strip() for o in extra.split(",") if o.strip()])
|
|
||||||
return list(set(origins)) # deduplicate
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup: Start background data fetching, AIS stream, and carrier tracker
|
# Startup: Start background data fetching, AIS stream, and carrier tracker
|
||||||
@@ -57,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=_build_cors_origins(),
|
allow_origins=["*"], # For prototyping, allow all
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -87,17 +59,15 @@ 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", []),
|
||||||
"gps_jamming": d.get("gps_jamming", []),
|
"gps_jamming": d.get("gps_jamming", []),
|
||||||
"freshness": dict(source_timestamps),
|
|
||||||
}
|
}
|
||||||
# ETag includes last_updated timestamp so it changes on every data refresh,
|
# ETag includes last_updated timestamp so it changes on every data refresh,
|
||||||
# not just when item counts change (old bug: positions went stale)
|
# not just when item counts change (old bug: positions went stale)
|
||||||
last_updated = d.get("last_updated", "")
|
last_updated = d.get("last_updated", "")
|
||||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items() if k != "freshness")
|
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
||||||
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
|
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
@@ -121,17 +91,11 @@ 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", []),
|
|
||||||
"space_weather": d.get("space_weather"),
|
|
||||||
"internet_outages": d.get("internet_outages", []),
|
|
||||||
"firms_fires": d.get("firms_fires", []),
|
|
||||||
"datacenters": d.get("datacenters", []),
|
|
||||||
"freshness": dict(source_timestamps),
|
|
||||||
}
|
}
|
||||||
# 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", "")
|
||||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items() if k != "freshness")
|
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
|
||||||
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
|
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
@@ -148,30 +112,7 @@ async def debug_latest_data():
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
import time
|
return {"status": "ok"}
|
||||||
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", [])),
|
|
||||||
"uavs": len(d.get("uavs", [])),
|
|
||||||
"firms_fires": len(d.get("firms_fires", [])),
|
|
||||||
"liveuamap": len(d.get("liveuamap", [])),
|
|
||||||
"gdelt": len(d.get("gdelt", [])),
|
|
||||||
},
|
|
||||||
"freshness": dict(source_timestamps),
|
|
||||||
"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
|
||||||
|
|
||||||
@@ -227,13 +168,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -255,34 +189,6 @@ async def api_update_key(body: ApiKeyUpdate):
|
|||||||
return {"status": "updated", "env_key": body.env_key}
|
return {"status": "updated", "env_key": body.env_key}
|
||||||
return {"status": "error", "message": "Failed to update .env file"}
|
return {"status": "error", "message": "Failed to update .env file"}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# News Feed Configuration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
from services.news_feed_config import get_feeds, save_feeds, reset_feeds
|
|
||||||
|
|
||||||
@app.get("/api/settings/news-feeds")
|
|
||||||
async def api_get_news_feeds():
|
|
||||||
return get_feeds()
|
|
||||||
|
|
||||||
@app.put("/api/settings/news-feeds")
|
|
||||||
async def api_save_news_feeds(request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
ok = save_feeds(body)
|
|
||||||
if ok:
|
|
||||||
return {"status": "updated", "count": len(body)}
|
|
||||||
return Response(
|
|
||||||
content=json_mod.dumps({"status": "error", "message": "Validation failed (max 20 feeds, each needs name/url/weight 1-5)"}),
|
|
||||||
status_code=400,
|
|
||||||
media_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/api/settings/news-feeds/reset")
|
|
||||||
async def api_reset_news_feeds():
|
|
||||||
ok = reset_feeds()
|
|
||||||
if ok:
|
|
||||||
return {"status": "reset", "feeds": get_feeds()}
|
|
||||||
return {"status": "error", "message": "Failed to reset feeds"}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,10 @@
|
|||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
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", "")
|
API_KEY = os.environ.get("AIS_API_KEY", "75cc39af03c9cc23c90e8a7b3c3bc2b2a507c5fb")
|
||||||
|
|
||||||
# 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,10 +211,9 @@ 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...")
|
||||||
@@ -324,12 +323,8 @@ 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(f"Restarting AIS proxy in {backoff}s (exponential backoff)...")
|
logger.info("Restarting AIS proxy in 5 seconds...")
|
||||||
time.sleep(backoff)
|
time.sleep(5)
|
||||||
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,29 +145,20 @@ 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,
|
||||||
"is_set": False,
|
"value_plain": None,
|
||||||
}
|
}
|
||||||
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["is_set"] = bool(raw)
|
entry["value_plain"] = raw # Sent only when reveal is requested
|
||||||
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."""
|
||||||
valid_keys = {api["env_key"] for api in API_REGISTRY if api.get("env_key")}
|
|
||||||
if env_key not in valid_keys:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not isinstance(new_value, str):
|
|
||||||
return False
|
|
||||||
if "\n" in new_value or "\r" in new_value:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not ENV_PATH.exists():
|
if not ENV_PATH.exists():
|
||||||
ENV_PATH.write_text("", encoding="utf-8")
|
return False
|
||||||
|
|
||||||
# Update os.environ immediately
|
# Update os.environ immediately
|
||||||
os.environ[env_key] = new_value
|
os.environ[env_key] = new_value
|
||||||
|
|||||||
+149
-627
@@ -10,7 +10,6 @@ import random
|
|||||||
import math
|
import math
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
import threading
|
import threading
|
||||||
import io
|
import io
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
@@ -73,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", ""),
|
client_id=os.environ.get("OPENSKY_CLIENT_ID", "vancecook-api-client"),
|
||||||
client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", "")
|
client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", "YOUR_OPENSKY_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
|
||||||
@@ -101,23 +100,9 @@ latest_data = {
|
|||||||
"uavs": [],
|
"uavs": [],
|
||||||
"frontlines": None,
|
"frontlines": None,
|
||||||
"gdelt": [],
|
"gdelt": [],
|
||||||
"liveuamap": [],
|
"liveuamap": []
|
||||||
"kiwisdr": [],
|
|
||||||
"space_weather": None,
|
|
||||||
"internet_outages": [],
|
|
||||||
"firms_fires": [],
|
|
||||||
"datacenters": []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Per-source freshness timestamps — updated each time a fetch function completes successfully
|
|
||||||
source_timestamps = {}
|
|
||||||
|
|
||||||
def _mark_fresh(*keys):
|
|
||||||
"""Record the current UTC time for one or more data source keys."""
|
|
||||||
now = datetime.utcnow().isoformat()
|
|
||||||
for k in keys:
|
|
||||||
source_timestamps[k] = now
|
|
||||||
|
|
||||||
# Thread lock for safe reads/writes to latest_data
|
# Thread lock for safe reads/writes to latest_data
|
||||||
_data_lock = threading.Lock()
|
_data_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -348,10 +333,20 @@ _KEYWORD_COORDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
def fetch_news():
|
def fetch_news():
|
||||||
from services.news_feed_config import get_feeds
|
feeds = {
|
||||||
feed_config = get_feeds()
|
"NPR": "https://feeds.npr.org/1004/rss.xml",
|
||||||
feeds = {f["name"]: f["url"] for f in feed_config}
|
"BBC": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||||
source_weights = {f["name"]: f["weight"] for f in feed_config}
|
"AlJazeera": "https://www.aljazeera.com/xml/rss/all.xml",
|
||||||
|
"NYT": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
||||||
|
"GDACS": "https://www.gdacs.org/xml/rss.xml",
|
||||||
|
"NHK": "https://www3.nhk.or.jp/nhkworld/rss/world.xml",
|
||||||
|
"CNA": "https://www.channelnewsasia.com/rssfeed/8395986",
|
||||||
|
"Mercopress": "https://en.mercopress.com/rss/"
|
||||||
|
}
|
||||||
|
source_weights = {
|
||||||
|
"NPR": 4, "BBC": 3, "AlJazeera": 2, "NYT": 1,
|
||||||
|
"GDACS": 5, "NHK": 3, "CNA": 3, "Mercopress": 3
|
||||||
|
}
|
||||||
|
|
||||||
clusters = {}
|
clusters = {}
|
||||||
|
|
||||||
@@ -478,7 +473,6 @@ def fetch_news():
|
|||||||
|
|
||||||
news_items.sort(key=lambda x: x['risk_score'], reverse=True)
|
news_items.sort(key=lambda x: x['risk_score'], reverse=True)
|
||||||
latest_data['news'] = news_items
|
latest_data['news'] = news_items
|
||||||
_mark_fresh("news")
|
|
||||||
|
|
||||||
def fetch_defense_stocks():
|
def fetch_defense_stocks():
|
||||||
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
||||||
@@ -502,7 +496,6 @@ def fetch_defense_stocks():
|
|||||||
logger.warning(f"Could not fetch data for {t}: {e}")
|
logger.warning(f"Could not fetch data for {t}: {e}")
|
||||||
|
|
||||||
latest_data['stocks'] = stocks_data
|
latest_data['stocks'] = stocks_data
|
||||||
_mark_fresh("stocks")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching stocks: {e}")
|
logger.error(f"Error fetching stocks: {e}")
|
||||||
|
|
||||||
@@ -529,7 +522,6 @@ def fetch_oil_prices():
|
|||||||
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
||||||
|
|
||||||
latest_data['oil'] = oil_data
|
latest_data['oil'] = oil_data
|
||||||
_mark_fresh("oil")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching oil: {e}")
|
logger.error(f"Error fetching oil: {e}")
|
||||||
|
|
||||||
@@ -766,11 +758,6 @@ def fetch_flights():
|
|||||||
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
||||||
|
|
||||||
model_upper = f.get("t", "").upper()
|
model_upper = f.get("t", "").upper()
|
||||||
|
|
||||||
# Skip fixed structures (towers, oil platforms) that broadcast ADS-B
|
|
||||||
if model_upper == "TWR":
|
|
||||||
continue
|
|
||||||
|
|
||||||
ac_category = "heli" if model_upper in _HELI_TYPES_BACKEND else "plane"
|
ac_category = "heli" if model_upper in _HELI_TYPES_BACKEND else "plane"
|
||||||
|
|
||||||
flights.append({
|
flights.append({
|
||||||
@@ -898,12 +885,9 @@ 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())
|
||||||
|
|
||||||
with _data_lock:
|
latest_data['commercial_flights'] = _merge_category(commercial, latest_data.get('commercial_flights', []))
|
||||||
latest_data['commercial_flights'] = _merge_category(commercial, latest_data.get('commercial_flights', []))
|
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
|
||||||
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
|
latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', []))
|
||||||
latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', []))
|
|
||||||
|
|
||||||
_mark_fresh("commercial_flights", "private_jets", "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:
|
||||||
@@ -980,39 +964,27 @@ 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
|
||||||
with _trails_lock:
|
for flist in all_lists:
|
||||||
for flist in all_lists:
|
for f in flist:
|
||||||
for f in flist:
|
count, hex_id = _accumulate_trail(f, now_ts, check_route=True)
|
||||||
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)
|
||||||
|
|
||||||
# Prune stale trails (10 min for non-tracked, 30 min for tracked)
|
# Also process military flights (separate list)
|
||||||
tracked_hexes = {t.get('icao24', '').lower() for t in latest_data.get('tracked_flights', [])}
|
for mf in latest_data.get('military_flights', []):
|
||||||
stale_keys = []
|
count, hex_id = _accumulate_trail(mf, now_ts, check_route=False)
|
||||||
for k, v in flight_trails.items():
|
trail_count += count
|
||||||
cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 600
|
if hex_id:
|
||||||
if v['last_seen'] < cutoff:
|
seen_hexes.add(hex_id)
|
||||||
stale_keys.append(k)
|
|
||||||
for k in stale_keys:
|
# Prune trails for aircraft not seen in 30 minutes
|
||||||
del flight_trails[k]
|
stale_cutoff = now_ts - 1800
|
||||||
|
stale_keys = [k for k, v in flight_trails.items() if v['last_seen'] < stale_cutoff]
|
||||||
# Enforce global cap — evict oldest trails first
|
for k in stale_keys:
|
||||||
if len(flight_trails) > _MAX_TRACKED_TRAILS:
|
del flight_trails[k]
|
||||||
sorted_keys = sorted(flight_trails.keys(), key=lambda k: flight_trails[k]['last_seen'])
|
|
||||||
evict_count = len(flight_trails) - _MAX_TRACKED_TRAILS
|
logger.info(f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned")
|
||||||
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
|
||||||
@@ -1123,65 +1095,26 @@ def fetch_ships():
|
|||||||
|
|
||||||
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
|
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
|
||||||
latest_data['ships'] = ships
|
latest_data['ships'] = ships
|
||||||
_mark_fresh("ships")
|
|
||||||
|
|
||||||
def fetch_military_flights():
|
def fetch_military_flights():
|
||||||
# True ADS-B Exchange military data requires paid API access.
|
# True ADS-B Exchange military data requires paid API access.
|
||||||
# We will use adsb.lol (an open source ADSB aggregator) /v2/mil fallback.
|
# We will use adsb.lol (an open source ADSB aggregator) /v2/mil fallback.
|
||||||
military_flights = []
|
military_flights = []
|
||||||
detected_uavs = []
|
|
||||||
try:
|
try:
|
||||||
url = "https://api.adsb.lol/v2/mil"
|
url = "https://api.adsb.lol/v2/mil"
|
||||||
response = fetch_with_curl(url, timeout=10)
|
response = fetch_with_curl(url, timeout=10)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
ac = response.json().get('ac', [])
|
ac = response.json().get('ac', [])
|
||||||
for f in ac:
|
for f in ac:
|
||||||
try:
|
try:
|
||||||
lat = f.get("lat")
|
lat = f.get("lat")
|
||||||
lng = f.get("lon")
|
lng = f.get("lon")
|
||||||
heading = f.get("track") or 0
|
heading = f.get("track") or 0
|
||||||
|
|
||||||
if lat is None or lng is None:
|
if lat is None or lng is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
model = str(f.get("t", "UNKNOWN")).upper()
|
model = str(f.get("t", "UNKNOWN")).upper()
|
||||||
callsign = str(f.get("flight", "MIL-UNKN")).strip()
|
|
||||||
|
|
||||||
# Skip fixed structures (towers, oil platforms) that broadcast ADS-B
|
|
||||||
if model == "TWR":
|
|
||||||
continue
|
|
||||||
|
|
||||||
alt_raw = f.get("alt_baro")
|
|
||||||
alt_value = 0
|
|
||||||
if isinstance(alt_raw, (int, float)):
|
|
||||||
alt_value = alt_raw * 0.3048
|
|
||||||
|
|
||||||
# Ground speed from ADS-B (in knots)
|
|
||||||
gs_knots = f.get("gs")
|
|
||||||
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
|
||||||
|
|
||||||
# Check if this is a UAV/drone before classifying as regular military
|
|
||||||
is_uav, uav_type, wiki_url = _classify_uav(model, callsign)
|
|
||||||
if is_uav:
|
|
||||||
detected_uavs.append({
|
|
||||||
"id": f"uav-{f.get('hex', '')}",
|
|
||||||
"callsign": callsign,
|
|
||||||
"aircraft_model": f.get("t", "Unknown"),
|
|
||||||
"lat": float(lat),
|
|
||||||
"lng": float(lng),
|
|
||||||
"alt": alt_value,
|
|
||||||
"heading": heading,
|
|
||||||
"speed_knots": speed_knots,
|
|
||||||
"country": f.get("r", "Unknown"),
|
|
||||||
"uav_type": uav_type,
|
|
||||||
"wiki": wiki_url or "",
|
|
||||||
"type": "uav",
|
|
||||||
"registration": f.get("r", "N/A"),
|
|
||||||
"icao24": f.get("hex", ""),
|
|
||||||
"squawk": f.get("squawk", ""),
|
|
||||||
})
|
|
||||||
continue # Don't double-count as military flight
|
|
||||||
|
|
||||||
mil_cat = "default"
|
mil_cat = "default"
|
||||||
if "H" in model and any(c.isdigit() for c in model):
|
if "H" in model and any(c.isdigit() for c in model):
|
||||||
mil_cat = "heli"
|
mil_cat = "heli"
|
||||||
@@ -1191,11 +1124,27 @@ def fetch_military_flights():
|
|||||||
mil_cat = "fighter"
|
mil_cat = "fighter"
|
||||||
elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]):
|
elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]):
|
||||||
mil_cat = "cargo"
|
mil_cat = "cargo"
|
||||||
elif any(k in model for k in ["P8", "E3", "E8", "U2"]):
|
elif any(k in model for k in ["P8", "E3", "E8", "U2", "RQ", "MQ"]):
|
||||||
mil_cat = "recon"
|
mil_cat = "recon"
|
||||||
|
|
||||||
|
# Military flights don't file public routes
|
||||||
|
origin_loc = None
|
||||||
|
dest_loc = None
|
||||||
|
origin_name = "UNKNOWN"
|
||||||
|
dest_name = "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
alt_raw = f.get("alt_baro")
|
||||||
|
alt_value = 0
|
||||||
|
if isinstance(alt_raw, (int, float)):
|
||||||
|
alt_value = alt_raw * 0.3048
|
||||||
|
|
||||||
|
# Ground speed from ADS-B (in knots)
|
||||||
|
gs_knots = f.get("gs")
|
||||||
|
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
||||||
|
|
||||||
military_flights.append({
|
military_flights.append({
|
||||||
"callsign": callsign,
|
"callsign": str(f.get("flight", "MIL-UNKN")).strip(),
|
||||||
"country": f.get("r", "Military Asset"),
|
"country": f.get("r", "Military Asset"),
|
||||||
"lng": float(lng),
|
"lng": float(lng),
|
||||||
"lat": float(lat),
|
"lat": float(lat),
|
||||||
@@ -1203,10 +1152,10 @@ def fetch_military_flights():
|
|||||||
"heading": heading,
|
"heading": heading,
|
||||||
"type": "military_flight",
|
"type": "military_flight",
|
||||||
"military_type": mil_cat,
|
"military_type": mil_cat,
|
||||||
"origin_loc": None,
|
"origin_loc": origin_loc,
|
||||||
"dest_loc": None,
|
"dest_loc": dest_loc,
|
||||||
"origin_name": "UNKNOWN",
|
"origin_name": origin_name,
|
||||||
"dest_name": "UNKNOWN",
|
"dest_name": dest_name,
|
||||||
"registration": f.get("r", "N/A"),
|
"registration": f.get("r", "N/A"),
|
||||||
"model": f.get("t", "Unknown"),
|
"model": f.get("t", "Unknown"),
|
||||||
"icao24": f.get("hex", ""),
|
"icao24": f.get("hex", ""),
|
||||||
@@ -1218,18 +1167,15 @@ def fetch_military_flights():
|
|||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching military flights: {e}")
|
logger.error(f"Error fetching military flights: {e}")
|
||||||
|
|
||||||
if not military_flights and not detected_uavs:
|
if not military_flights:
|
||||||
# API failed or rate limited — log but do NOT inject fake data
|
# API failed or rate limited — log but do NOT inject fake data
|
||||||
logger.warning("No military flights retrieved — keeping previous data if available")
|
logger.warning("No military flights retrieved — keeping previous data if available")
|
||||||
# Preserve existing data rather than overwriting with empty
|
# Preserve existing data rather than overwriting with empty
|
||||||
if latest_data.get('military_flights'):
|
if latest_data.get('military_flights'):
|
||||||
return
|
return
|
||||||
|
|
||||||
latest_data['military_flights'] = military_flights
|
latest_data['military_flights'] = military_flights
|
||||||
latest_data['uavs'] = detected_uavs
|
|
||||||
_mark_fresh("military_flights", "uavs")
|
|
||||||
logger.info(f"UAVs: {len(detected_uavs)} real drones detected via ADS-B")
|
|
||||||
|
|
||||||
# Cross-reference military flights with Plane-Alert DB
|
# Cross-reference military flights with Plane-Alert DB
|
||||||
tracked_mil = []
|
tracked_mil = []
|
||||||
@@ -1281,327 +1227,16 @@ def fetch_weather():
|
|||||||
if "radar" in data and "past" in data["radar"]:
|
if "radar" in data and "past" in data["radar"]:
|
||||||
latest_time = data["radar"]["past"][-1]["time"]
|
latest_time = data["radar"]["past"][-1]["time"]
|
||||||
latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")}
|
latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")}
|
||||||
_mark_fresh("weather")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching weather: {e}")
|
logger.error(f"Error fetching weather: {e}")
|
||||||
|
|
||||||
def fetch_cctv():
|
def fetch_cctv():
|
||||||
try:
|
try:
|
||||||
latest_data["cctv"] = get_all_cameras()
|
latest_data["cctv"] = get_all_cameras()
|
||||||
_mark_fresh("cctv")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
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()
|
|
||||||
_mark_fresh("kiwisdr")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching KiwiSDR nodes: {e}")
|
|
||||||
latest_data["kiwisdr"] = []
|
|
||||||
|
|
||||||
def fetch_firms_fires():
|
|
||||||
"""Fetch global fire/thermal anomalies from NASA FIRMS (NOAA-20 VIIRS, 24h, no key needed)."""
|
|
||||||
fires = []
|
|
||||||
try:
|
|
||||||
url = "https://firms.modaps.eosdis.nasa.gov/data/active_fire/noaa-20-viirs-c2/csv/J1_VIIRS_C2_Global_24h.csv"
|
|
||||||
response = fetch_with_curl(url, timeout=30)
|
|
||||||
if response.status_code == 200:
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
reader = csv.DictReader(io.StringIO(response.text))
|
|
||||||
all_rows = []
|
|
||||||
for row in reader:
|
|
||||||
try:
|
|
||||||
lat = float(row.get("latitude", 0))
|
|
||||||
lng = float(row.get("longitude", 0))
|
|
||||||
frp = float(row.get("frp", 0)) # Fire Radiative Power (MW)
|
|
||||||
conf = row.get("confidence", "nominal")
|
|
||||||
daynight = row.get("daynight", "")
|
|
||||||
bright = float(row.get("bright_ti4", 0))
|
|
||||||
all_rows.append({
|
|
||||||
"lat": lat,
|
|
||||||
"lng": lng,
|
|
||||||
"frp": frp,
|
|
||||||
"brightness": bright,
|
|
||||||
"confidence": conf,
|
|
||||||
"daynight": daynight,
|
|
||||||
"acq_date": row.get("acq_date", ""),
|
|
||||||
"acq_time": row.get("acq_time", ""),
|
|
||||||
})
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
# Sort by FRP descending, keep top 5000 (most intense fires first)
|
|
||||||
all_rows.sort(key=lambda x: x["frp"], reverse=True)
|
|
||||||
fires = all_rows[:5000]
|
|
||||||
logger.info(f"FIRMS fires: {len(fires)} hotspots (from {response.status_code})")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching FIRMS fires: {e}")
|
|
||||||
latest_data["firms_fires"] = fires
|
|
||||||
if fires:
|
|
||||||
_mark_fresh("firms_fires")
|
|
||||||
|
|
||||||
def fetch_space_weather():
|
|
||||||
"""Fetch NOAA SWPC Kp index and recent solar events."""
|
|
||||||
try:
|
|
||||||
kp_resp = fetch_with_curl("https://services.swpc.noaa.gov/json/planetary_k_index_1m.json", timeout=10)
|
|
||||||
kp_value = None
|
|
||||||
kp_text = "QUIET"
|
|
||||||
if kp_resp.status_code == 200:
|
|
||||||
kp_data = kp_resp.json()
|
|
||||||
if kp_data:
|
|
||||||
latest_kp = kp_data[-1]
|
|
||||||
kp_value = float(latest_kp.get("kp_index", 0))
|
|
||||||
if kp_value >= 7:
|
|
||||||
kp_text = f"STORM G{min(int(kp_value) - 4, 5)}"
|
|
||||||
elif kp_value >= 5:
|
|
||||||
kp_text = f"STORM G{min(int(kp_value) - 4, 5)}"
|
|
||||||
elif kp_value >= 4:
|
|
||||||
kp_text = "ACTIVE"
|
|
||||||
elif kp_value >= 3:
|
|
||||||
kp_text = "UNSETTLED"
|
|
||||||
|
|
||||||
events = []
|
|
||||||
ev_resp = fetch_with_curl("https://services.swpc.noaa.gov/json/edited_events.json", timeout=10)
|
|
||||||
if ev_resp.status_code == 200:
|
|
||||||
all_events = ev_resp.json()
|
|
||||||
for ev in all_events[-10:]:
|
|
||||||
events.append({
|
|
||||||
"type": ev.get("type", ""),
|
|
||||||
"begin": ev.get("begin", ""),
|
|
||||||
"end": ev.get("end", ""),
|
|
||||||
"classtype": ev.get("classtype", ""),
|
|
||||||
})
|
|
||||||
|
|
||||||
latest_data["space_weather"] = {
|
|
||||||
"kp_index": kp_value,
|
|
||||||
"kp_text": kp_text,
|
|
||||||
"events": events,
|
|
||||||
}
|
|
||||||
_mark_fresh("space_weather")
|
|
||||||
logger.info(f"Space weather: Kp={kp_value} ({kp_text}), {len(events)} events")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching space weather: {e}")
|
|
||||||
|
|
||||||
# Cache geocoded region coordinates so we only hit Nominatim once per region
|
|
||||||
_region_geocode_cache: dict = {}
|
|
||||||
|
|
||||||
def _geocode_region(region_name: str, country_name: str) -> tuple:
|
|
||||||
"""Geocode a region using OpenStreetMap Nominatim (cached, respects rate limit)."""
|
|
||||||
cache_key = f"{region_name}|{country_name}"
|
|
||||||
if cache_key in _region_geocode_cache:
|
|
||||||
return _region_geocode_cache[cache_key]
|
|
||||||
try:
|
|
||||||
import urllib.parse
|
|
||||||
query = urllib.parse.quote(f"{region_name}, {country_name}")
|
|
||||||
url = f"https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=1"
|
|
||||||
response = fetch_with_curl(url, timeout=8, headers={"User-Agent": "ShadowBroker-OSINT/1.0"})
|
|
||||||
if response.status_code == 200:
|
|
||||||
results = response.json()
|
|
||||||
if results:
|
|
||||||
lat = float(results[0]["lat"])
|
|
||||||
lon = float(results[0]["lon"])
|
|
||||||
_region_geocode_cache[cache_key] = (lat, lon)
|
|
||||||
return (lat, lon)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_region_geocode_cache[cache_key] = None
|
|
||||||
return None
|
|
||||||
|
|
||||||
def fetch_internet_outages():
|
|
||||||
"""Fetch regional internet outage alerts from IODA (Georgia Tech).
|
|
||||||
Region-level only — higher fidelity than country-level. If an entire country
|
|
||||||
is down, all its regions will show up individually.
|
|
||||||
|
|
||||||
Only uses reliable datasources (bgp, ping-slash24) that measure actual
|
|
||||||
connectivity. Excludes merit-nt (network telescope with tiny sample sizes
|
|
||||||
that produces wildly misleading percentages for large regions)."""
|
|
||||||
# Datasources that actually measure real internet connectivity
|
|
||||||
RELIABLE_DATASOURCES = {"bgp", "ping-slash24"}
|
|
||||||
outages = []
|
|
||||||
try:
|
|
||||||
now = int(time.time())
|
|
||||||
start = now - 86400
|
|
||||||
url = f"https://api.ioda.inetintel.cc.gatech.edu/v2/outages/alerts?from={start}&until={now}&limit=500"
|
|
||||||
response = fetch_with_curl(url, timeout=15)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
alerts = data.get("data", [])
|
|
||||||
# Collect region-level outages (deduplicate by region code, keep worst)
|
|
||||||
region_outages = {}
|
|
||||||
for alert in alerts:
|
|
||||||
entity = alert.get("entity", {})
|
|
||||||
etype = entity.get("type", "")
|
|
||||||
level = alert.get("level", "")
|
|
||||||
if level == "normal" or etype != "region":
|
|
||||||
continue
|
|
||||||
datasource = alert.get("datasource", "")
|
|
||||||
if datasource not in RELIABLE_DATASOURCES:
|
|
||||||
continue # Skip merit-nt and other unreliable sources
|
|
||||||
code = entity.get("code", "")
|
|
||||||
name = entity.get("name", "")
|
|
||||||
attrs = entity.get("attrs", {})
|
|
||||||
country_code = attrs.get("country_code", "")
|
|
||||||
country_name = attrs.get("country_name", "")
|
|
||||||
value = alert.get("value", 0)
|
|
||||||
history_value = alert.get("historyValue", 0)
|
|
||||||
severity = 0
|
|
||||||
if history_value and history_value > 0:
|
|
||||||
severity = round((1 - value / history_value) * 100)
|
|
||||||
severity = max(0, min(severity, 100))
|
|
||||||
if severity < 10:
|
|
||||||
continue # Skip minor fluctuations (<10% is normal jitter)
|
|
||||||
if code not in region_outages or severity > region_outages[code]["severity"]:
|
|
||||||
region_outages[code] = {
|
|
||||||
"region_code": code,
|
|
||||||
"region_name": name,
|
|
||||||
"country_code": country_code,
|
|
||||||
"country_name": country_name,
|
|
||||||
"level": level,
|
|
||||||
"datasource": datasource,
|
|
||||||
"severity": severity,
|
|
||||||
}
|
|
||||||
# Geocode regions and build final list
|
|
||||||
geocoded = []
|
|
||||||
for rcode, r in region_outages.items():
|
|
||||||
coords = _geocode_region(r["region_name"], r["country_name"])
|
|
||||||
if coords:
|
|
||||||
r["lat"] = coords[0]
|
|
||||||
r["lng"] = coords[1]
|
|
||||||
geocoded.append(r)
|
|
||||||
# Sort by severity descending, cap at 100
|
|
||||||
geocoded.sort(key=lambda x: x["severity"], reverse=True)
|
|
||||||
outages = geocoded[:100]
|
|
||||||
logger.info(f"Internet outages: {len(outages)} regions affected")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching internet outages: {e}")
|
|
||||||
latest_data["internet_outages"] = outages
|
|
||||||
if outages:
|
|
||||||
_mark_fresh("internet_outages")
|
|
||||||
|
|
||||||
_DC_CACHE_PATH = Path(__file__).parent.parent / "data" / "datacenters.json"
|
|
||||||
_DC_URL = "https://raw.githubusercontent.com/Ringmast4r/Data-Center-Map---Global/1f290297c6a11454dc7a47bf95aef7cf0fe1d34c/datacenters_cleaned.json"
|
|
||||||
|
|
||||||
# Country bounding boxes (lat_min, lat_max, lng_min, lng_max) for coordinate validation.
|
|
||||||
# The source dataset has abs(lat) for all Southern Hemisphere entries, so we fix the sign
|
|
||||||
# and then validate the result falls within the country's bounding box.
|
|
||||||
_COUNTRY_BBOX: dict[str, tuple[float, float, float, float]] = {
|
|
||||||
"Argentina": (-55, -21, -74, -53), "Australia": (-44, -10, 112, 154),
|
|
||||||
"Bolivia": (-23, -9, -70, -57), "Brazil": (-34, 6, -74, -34),
|
|
||||||
"Chile": (-56, -17, -76, -66), "Colombia": (-5, 13, -82, -66),
|
|
||||||
"Ecuador": (-5, 2, -81, -75), "Indonesia": (-11, 6, 95, 141),
|
|
||||||
"Kenya": (-5, 5, 34, 42), "Madagascar": (-26, -12, 43, 51),
|
|
||||||
"Mozambique": (-27, -10, 30, 41), "New Zealand": (-47, -34, 166, 179),
|
|
||||||
"Paraguay": (-28, -19, -63, -54), "Peru": (-18, 0, -82, -68),
|
|
||||||
"South Africa": (-35, -22, 16, 33), "Tanzania": (-12, -1, 29, 41),
|
|
||||||
"Uruguay": (-35, -30, -59, -53), "Zimbabwe": (-23, -15, 25, 34),
|
|
||||||
# Northern-hemisphere countries for validation only
|
|
||||||
"United States": (24, 72, -180, -65), "Canada": (41, 84, -141, -52),
|
|
||||||
"United Kingdom": (49, 61, -9, 2), "Germany": (47, 55, 5, 16),
|
|
||||||
"France": (41, 51, -5, 10), "Japan": (24, 46, 123, 146),
|
|
||||||
"India": (6, 36, 68, 98), "China": (18, 54, 73, 135),
|
|
||||||
"Singapore": (1, 2, 103, 105), "Spain": (36, 44, -10, 5),
|
|
||||||
"Netherlands": (50, 54, 3, 8), "Sweden": (55, 70, 11, 25),
|
|
||||||
"Italy": (36, 47, 6, 19), "Russia": (41, 82, 19, 180),
|
|
||||||
"Mexico": (14, 33, -118, -86), "Nigeria": (4, 14, 2, 15),
|
|
||||||
"Thailand": (5, 21, 97, 106), "Malaysia": (0, 8, 99, 120),
|
|
||||||
"Philippines": (4, 21, 116, 127), "South Korea": (33, 39, 124, 132),
|
|
||||||
"Taiwan": (21, 26, 119, 123), "Hong Kong": (22, 23, 113, 115),
|
|
||||||
"Vietnam": (8, 24, 102, 110), "Poland": (49, 55, 14, 25),
|
|
||||||
"Switzerland": (45, 48, 5, 11), "Austria": (46, 49, 9, 17),
|
|
||||||
"Belgium": (49, 52, 2, 7), "Denmark": (54, 58, 8, 16),
|
|
||||||
"Finland": (59, 70, 20, 32), "Norway": (57, 72, 4, 32),
|
|
||||||
"Ireland": (51, 56, -11, -5), "Portugal": (36, 42, -10, -6),
|
|
||||||
"Turkey": (35, 42, 25, 45), "Israel": (29, 34, 34, 36),
|
|
||||||
"UAE": (22, 27, 51, 56), "Saudi Arabia": (16, 33, 34, 56),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Countries whose DCs always sit south of the equator
|
|
||||||
_SOUTHERN_COUNTRIES = {
|
|
||||||
"Argentina", "Australia", "Bolivia", "Brazil", "Chile", "Madagascar",
|
|
||||||
"Mozambique", "New Zealand", "Paraguay", "Peru", "South Africa",
|
|
||||||
"Tanzania", "Uruguay", "Zimbabwe",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _fix_dc_coords(lat: float, lng: float, country: str) -> tuple[float, float] | None:
|
|
||||||
"""Fix and validate data-center coordinates against the stated country.
|
|
||||||
|
|
||||||
The source dataset stores abs(lat) for Southern-Hemisphere entries.
|
|
||||||
We negate lat when the country is in the Southern Hemisphere, then
|
|
||||||
validate the result falls within the country bounding box (if known).
|
|
||||||
Returns corrected (lat, lng) or None if the coords are clearly wrong.
|
|
||||||
"""
|
|
||||||
# Fix Southern Hemisphere sign
|
|
||||||
if country in _SOUTHERN_COUNTRIES and lat > 0:
|
|
||||||
lat = -lat
|
|
||||||
|
|
||||||
bbox = _COUNTRY_BBOX.get(country)
|
|
||||||
if bbox:
|
|
||||||
lat_min, lat_max, lng_min, lng_max = bbox
|
|
||||||
if lat_min <= lat <= lat_max and lng_min <= lng <= lng_max:
|
|
||||||
return lat, lng
|
|
||||||
# Try swapping sign as last resort (some entries are just wrong sign)
|
|
||||||
if lat_min <= -lat <= lat_max and lng_min <= lng <= lng_max:
|
|
||||||
return -lat, lng
|
|
||||||
# Coords don't match country at all — drop the entry
|
|
||||||
return None
|
|
||||||
|
|
||||||
# No bbox for this country — basic sanity only
|
|
||||||
return lat, lng
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_datacenters():
|
|
||||||
"""Load data center locations (static dataset, cached locally after first fetch)."""
|
|
||||||
dcs = []
|
|
||||||
try:
|
|
||||||
raw = None
|
|
||||||
# Use local cache if it exists and is less than 7 days old
|
|
||||||
if _DC_CACHE_PATH.exists():
|
|
||||||
age_days = (time.time() - _DC_CACHE_PATH.stat().st_mtime) / 86400
|
|
||||||
if age_days < 7:
|
|
||||||
raw = json.loads(_DC_CACHE_PATH.read_text(encoding="utf-8"))
|
|
||||||
# Otherwise fetch from GitHub
|
|
||||||
if raw is None:
|
|
||||||
resp = fetch_with_curl(_DC_URL, timeout=20)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
raw = resp.json()
|
|
||||||
_DC_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
_DC_CACHE_PATH.write_text(json.dumps(raw), encoding="utf-8")
|
|
||||||
if raw:
|
|
||||||
dropped = 0
|
|
||||||
for entry in raw:
|
|
||||||
coords = entry.get("city_coords")
|
|
||||||
if not coords or not isinstance(coords, list) or len(coords) < 2:
|
|
||||||
continue
|
|
||||||
lat, lng = coords[0], coords[1]
|
|
||||||
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
|
|
||||||
continue
|
|
||||||
country = entry.get("country", "")
|
|
||||||
fixed = _fix_dc_coords(lat, lng, country)
|
|
||||||
if fixed is None:
|
|
||||||
dropped += 1
|
|
||||||
continue
|
|
||||||
lat, lng = fixed
|
|
||||||
dcs.append({
|
|
||||||
"name": entry.get("name", "Unknown"),
|
|
||||||
"company": entry.get("company", ""),
|
|
||||||
"city": entry.get("city", ""),
|
|
||||||
"country": country,
|
|
||||||
"lat": lat,
|
|
||||||
"lng": lng,
|
|
||||||
})
|
|
||||||
if dropped:
|
|
||||||
logger.info(f"Data centers: dropped {dropped} entries with mismatched coordinates")
|
|
||||||
logger.info(f"Data centers: {len(dcs)} with valid coordinates (from {'cache' if _DC_CACHE_PATH.exists() else 'GitHub'})")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error fetching data centers: {e}")
|
|
||||||
latest_data["datacenters"] = dcs
|
|
||||||
if dcs:
|
|
||||||
_mark_fresh("datacenters")
|
|
||||||
|
|
||||||
def fetch_bikeshare():
|
def fetch_bikeshare():
|
||||||
bikes = []
|
bikes = []
|
||||||
try:
|
try:
|
||||||
@@ -1659,8 +1294,6 @@ def fetch_earthquakes():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching earthquakes: {e}")
|
logger.error(f"Error fetching earthquakes: {e}")
|
||||||
latest_data["earthquakes"] = quakes
|
latest_data["earthquakes"] = quakes
|
||||||
if quakes:
|
|
||||||
_mark_fresh("earthquakes")
|
|
||||||
|
|
||||||
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes
|
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes
|
||||||
_sat_gp_cache = {"data": None, "last_fetch": 0}
|
_sat_gp_cache = {"data": None, "last_fetch": 0}
|
||||||
@@ -1716,84 +1349,6 @@ _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:
|
||||||
@@ -1801,40 +1356,16 @@ 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:
|
||||||
# Try multiple CelesTrak mirrors — .org is often blocked/banned by some networks
|
url = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json"
|
||||||
gp_urls = [
|
response = fetch_with_curl(url, timeout=15)
|
||||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
if response.status_code == 200:
|
||||||
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
_sat_gp_cache["data"] = response.json()
|
||||||
]
|
_sat_gp_cache["last_fetch"] = now_ts
|
||||||
for url in gp_urls:
|
logger.info(f"Satellites: Downloaded {len(_sat_gp_cache['data'])} GP records from CelesTrak")
|
||||||
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 from any source")
|
logger.warning("No satellite GP data available")
|
||||||
latest_data["satellites"] = sats
|
latest_data["satellites"] = sats
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1881,7 +1412,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('id', 0)
|
norad_id = s.get('NORAD_CAT_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
|
||||||
@@ -1960,83 +1491,82 @@ def fetch_satellites():
|
|||||||
# Only overwrite if we got data — don't wipe the map on API timeout
|
# Only overwrite if we got data — don't wipe the map on API timeout
|
||||||
if sats:
|
if sats:
|
||||||
latest_data["satellites"] = sats
|
latest_data["satellites"] = sats
|
||||||
_mark_fresh("satellites")
|
|
||||||
elif not latest_data.get("satellites"):
|
elif not latest_data.get("satellites"):
|
||||||
latest_data["satellites"] = []
|
latest_data["satellites"] = []
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def fetch_uavs():
|
||||||
# Real UAV detection from ADS-B data — filters military drone transponders
|
# Simulated high-altitude long-endurance (HALE) and MALE UAVs over high-risk regions
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_UAV_TYPE_CODES = {"Q9", "R4", "TB2", "MALE", "HALE", "HERM", "HRON"}
|
uav_targets = [
|
||||||
_UAV_CALLSIGN_PREFIXES = ("FORTE", "GHAWK", "REAP", "BAMS", "UAV", "UAS")
|
{
|
||||||
_UAV_MODEL_KEYWORDS = ("RQ-", "MQ-", "RQ4", "MQ9", "MQ4", "MQ1", "REAPER", "GLOBALHAWK", "TRITON", "PREDATOR", "HERMES", "HERON", "BAYRAKTAR")
|
"name": "RQ-4 Global Hawk", "center": [31.5, 34.8], "radius": 0.5, "alt": 15000,
|
||||||
_UAV_WIKI = {
|
"country": "USA", "uav_type": "HALE Surveillance", "range_km": 2200,
|
||||||
"RQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
"wiki": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||||
"RQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
"speed_knots": 340
|
||||||
"MQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
},
|
||||||
"MQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
{
|
||||||
"MQ9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
"name": "MQ-9 Reaper", "center": [49.0, 31.4], "radius": 1.2, "alt": 12000,
|
||||||
"MQ-9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
"country": "USA", "uav_type": "MALE Strike/ISR", "range_km": 1850,
|
||||||
"MQ1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
||||||
"MQ-1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
"speed_knots": 250
|
||||||
"REAPER": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper",
|
},
|
||||||
"GLOBALHAWK": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
{
|
||||||
"TRITON": "https://en.wikipedia.org/wiki/Northrop_Grumman_MQ-4C_Triton",
|
"name": "Bayraktar TB2", "center": [23.6, 120.9], "radius": 0.8, "alt": 8000,
|
||||||
"PREDATOR": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1_Predator",
|
"country": "Turkey", "uav_type": "MALE Strike", "range_km": 150,
|
||||||
"HERMES": "https://en.wikipedia.org/wiki/Elbit_Hermes_900",
|
"wiki": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
|
||||||
"HERON": "https://en.wikipedia.org/wiki/IAI_Heron",
|
"speed_knots": 120
|
||||||
"BAYRAKTAR": "https://en.wikipedia.org/wiki/Bayraktar_TB2",
|
},
|
||||||
}
|
{
|
||||||
|
"name": "MQ-1C Gray Eagle", "center": [38.0, 127.0], "radius": 0.4, "alt": 10000,
|
||||||
def _classify_uav(model: str, callsign: str):
|
"country": "USA", "uav_type": "MALE ISR/Strike", "range_km": 400,
|
||||||
"""Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords.
|
"wiki": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle",
|
||||||
Returns (is_uav, uav_type, wiki_url) or (False, None, None)."""
|
"speed_knots": 150
|
||||||
model_up = model.upper().replace(" ", "")
|
},
|
||||||
callsign_up = callsign.upper().strip()
|
{
|
||||||
|
"name": "RQ-170 Sentinel", "center": [25.0, 55.0], "radius": 1.5, "alt": 18000,
|
||||||
# Check ICAO type codes
|
"country": "USA", "uav_type": "Stealth ISR", "range_km": 1100,
|
||||||
if model_up in _UAV_TYPE_CODES:
|
"wiki": "https://en.wikipedia.org/wiki/Lockheed_Martin_RQ-170_Sentinel",
|
||||||
uav_type = "HALE Surveillance" if model_up in ("R4", "HALE") else "MALE ISR"
|
"speed_knots": 300
|
||||||
wiki = _UAV_WIKI.get(model_up, "")
|
}
|
||||||
return True, uav_type, wiki
|
]
|
||||||
|
|
||||||
# Check callsign prefixes (must also have a military-ish model)
|
# Use the current hour and minute to create a continuous slow orbit
|
||||||
for prefix in _UAV_CALLSIGN_PREFIXES:
|
now = datetime.utcnow()
|
||||||
if callsign_up.startswith(prefix):
|
# 1 full orbit every 10 minutes
|
||||||
uav_type = "HALE Surveillance" if prefix in ("FORTE", "GHAWK", "BAMS") else "MALE ISR"
|
time_factor = ((now.minute % 10) * 60 + now.second) / 600.0
|
||||||
wiki = _UAV_WIKI.get(prefix, "")
|
angle = time_factor * 2 * math.pi
|
||||||
if prefix == "FORTE":
|
|
||||||
wiki = _UAV_WIKI["RQ4"]
|
uavs = []
|
||||||
elif prefix == "BAMS":
|
for idx, t in enumerate(uav_targets):
|
||||||
wiki = _UAV_WIKI["MQ4"]
|
# Offset the angle slightly so they aren't all synchronized
|
||||||
return True, uav_type, wiki
|
offset_angle = angle + (idx * math.pi / 2.5)
|
||||||
|
|
||||||
# Check model keywords
|
lat = t["center"][0] + math.sin(offset_angle) * t["radius"]
|
||||||
for kw in _UAV_MODEL_KEYWORDS:
|
lng = t["center"][1] + math.cos(offset_angle) * t["radius"]
|
||||||
if kw in model_up:
|
|
||||||
# Determine type from keyword
|
heading = (math.degrees(offset_angle) + 90) % 360
|
||||||
if any(h in model_up for h in ("RQ4", "RQ-4", "GLOBALHAWK")):
|
|
||||||
return True, "HALE Surveillance", _UAV_WIKI.get(kw, "")
|
uavs.append({
|
||||||
elif any(h in model_up for h in ("MQ4", "MQ-4", "TRITON")):
|
"id": f"uav-{idx}",
|
||||||
return True, "HALE Maritime Surveillance", _UAV_WIKI.get(kw, "")
|
"callsign": t["name"],
|
||||||
elif any(h in model_up for h in ("MQ9", "MQ-9", "REAPER")):
|
"aircraft_model": t["name"],
|
||||||
return True, "MALE Strike/ISR", _UAV_WIKI.get(kw, "")
|
"lat": lat,
|
||||||
elif any(h in model_up for h in ("MQ1", "MQ-1", "PREDATOR")):
|
"lng": lng,
|
||||||
return True, "MALE ISR/Strike", _UAV_WIKI.get(kw, "")
|
"alt": t["alt"],
|
||||||
elif "BAYRAKTAR" in model_up or "TB2" in model_up:
|
"heading": heading,
|
||||||
return True, "MALE Strike", _UAV_WIKI.get("BAYRAKTAR", "")
|
"speed_knots": t["speed_knots"],
|
||||||
elif "HERMES" in model_up:
|
"center": t["center"],
|
||||||
return True, "MALE ISR", _UAV_WIKI.get("HERMES", "")
|
"orbit_radius": t["radius"],
|
||||||
elif "HERON" in model_up:
|
"range_km": t["range_km"],
|
||||||
return True, "MALE ISR", _UAV_WIKI.get("HERON", "")
|
"country": t["country"],
|
||||||
return True, "MALE ISR", _UAV_WIKI.get(kw, "")
|
"uav_type": t["uav_type"],
|
||||||
|
"wiki": t["wiki"],
|
||||||
return False, None, None
|
})
|
||||||
|
|
||||||
|
latest_data['uavs'] = 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)
|
||||||
|
|
||||||
@@ -2113,12 +1643,10 @@ def fetch_geopolitics():
|
|||||||
frontlines = fetch_ukraine_frontlines()
|
frontlines = fetch_ukraine_frontlines()
|
||||||
if frontlines:
|
if frontlines:
|
||||||
latest_data['frontlines'] = frontlines
|
latest_data['frontlines'] = frontlines
|
||||||
_mark_fresh("frontlines")
|
|
||||||
|
|
||||||
gdelt = fetch_global_military_incidents()
|
gdelt = fetch_global_military_incidents()
|
||||||
if gdelt is not None:
|
if gdelt is not None:
|
||||||
latest_data['gdelt'] = gdelt
|
latest_data['gdelt'] = gdelt
|
||||||
_mark_fresh("gdelt")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching geopolitics: {e}")
|
logger.error(f"Error fetching geopolitics: {e}")
|
||||||
|
|
||||||
@@ -2129,7 +1657,6 @@ def update_liveuamap():
|
|||||||
res = fetch_liveuamap()
|
res = fetch_liveuamap()
|
||||||
if res:
|
if res:
|
||||||
latest_data['liveuamap'] = res
|
latest_data['liveuamap'] = res
|
||||||
_mark_fresh("liveuamap")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Liveuamap scraper error: {e}")
|
logger.error(f"Liveuamap scraper error: {e}")
|
||||||
|
|
||||||
@@ -2138,8 +1665,9 @@ def update_fast_data():
|
|||||||
logger.info("Fast-tier data update starting...")
|
logger.info("Fast-tier data update starting...")
|
||||||
fast_funcs = [
|
fast_funcs = [
|
||||||
fetch_flights,
|
fetch_flights,
|
||||||
fetch_military_flights, # Also detects UAVs from ADS-B
|
fetch_military_flights,
|
||||||
fetch_ships,
|
fetch_ships,
|
||||||
|
fetch_uavs,
|
||||||
fetch_satellites,
|
fetch_satellites,
|
||||||
]
|
]
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fast_funcs)) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fast_funcs)) as executor:
|
||||||
@@ -2160,11 +1688,6 @@ def update_slow_data():
|
|||||||
fetch_cctv,
|
fetch_cctv,
|
||||||
fetch_earthquakes,
|
fetch_earthquakes,
|
||||||
fetch_geopolitics,
|
fetch_geopolitics,
|
||||||
fetch_kiwisdr,
|
|
||||||
fetch_space_weather,
|
|
||||||
fetch_internet_outages,
|
|
||||||
fetch_firms_fires,
|
|
||||||
fetch_datacenters,
|
|
||||||
]
|
]
|
||||||
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]
|
||||||
@@ -2190,7 +1713,7 @@ def start_scheduler():
|
|||||||
# Run full update once on startup
|
# Run full update once on startup
|
||||||
scheduler.add_job(update_all_data, 'date', run_date=datetime.now())
|
scheduler.add_job(update_all_data, 'date', run_date=datetime.now())
|
||||||
|
|
||||||
# Fast tier: every 60 seconds (flights, ships, military+UAVs, satellites)
|
# Fast tier: every 60 seconds (flights, ships, military, satellites, UAVs)
|
||||||
scheduler.add_job(update_fast_data, 'interval', seconds=60)
|
scheduler.add_job(update_fast_data, 'interval', seconds=60)
|
||||||
|
|
||||||
# Slow tier: every 30 minutes (news, stocks, weather, geopolitics)
|
# Slow tier: every 30 minutes (news, stocks, weather, geopolitics)
|
||||||
@@ -2228,6 +1751,5 @@ def stop_scheduler():
|
|||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
|
|
||||||
def get_latest_data():
|
def get_latest_data():
|
||||||
with _data_lock:
|
return latest_data
|
||||||
return dict(latest_data)
|
|
||||||
|
|
||||||
|
|||||||
@@ -285,17 +285,12 @@ 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 = []
|
links = [f'<div style="margin-bottom:6px;"><a href="{u}" target="_blank">{h}</a></div>' for u, h in zip(urls, headlines)]
|
||||||
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"] = html.escape(f["properties"]["name"])
|
f["properties"]["html"] = 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")
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
"""
|
|
||||||
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,19 +3,10 @@ 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"
|
||||||
@@ -59,10 +50,11 @@ 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 = _session.post(url, json=json_data, timeout=timeout, headers=default_headers)
|
res = requests.post(url, json=json_data, timeout=timeout, headers=default_headers)
|
||||||
else:
|
else:
|
||||||
res = _session.get(url, timeout=timeout, headers=default_headers)
|
res = requests.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)
|
||||||
@@ -71,21 +63,18 @@ 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 as argument list — never pass through shell to prevent injection
|
# Build curl command string for bash execution
|
||||||
_CURL_PATH = shutil.which("curl") or "curl"
|
header_flags = " ".join(f'-H "{k}: {v}"' for k, v in default_headers.items())
|
||||||
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:
|
||||||
cmd += ["-X", "POST", "-H", "Content-Type: application/json",
|
payload = json.dumps(json_data).replace('"', '\\"')
|
||||||
"--data-binary", "@-"]
|
curl_cmd = f'curl -s -w "\\n%{{http_code}}" {header_flags} -X POST -H "Content-Type: application/json" -d "{payload}" "{url}"'
|
||||||
cmd.append(url)
|
else:
|
||||||
|
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(
|
||||||
cmd, capture_output=True, text=True, timeout=timeout + 5,
|
[_BASH_PATH, "-c", curl_cmd],
|
||||||
input=stdin_data
|
capture_output=True, text=True, timeout=timeout + 5
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
"""
|
|
||||||
News feed configuration — manages the user-customisable RSS feed list.
|
|
||||||
Feeds are stored in backend/config/news_feeds.json and persist across restarts.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
CONFIG_PATH = Path(__file__).parent.parent / "config" / "news_feeds.json"
|
|
||||||
MAX_FEEDS = 20
|
|
||||||
|
|
||||||
DEFAULT_FEEDS = [
|
|
||||||
{"name": "NPR", "url": "https://feeds.npr.org/1004/rss.xml", "weight": 4},
|
|
||||||
{"name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "weight": 3},
|
|
||||||
{"name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2},
|
|
||||||
{"name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1},
|
|
||||||
{"name": "GDACS", "url": "https://www.gdacs.org/xml/rss.xml", "weight": 5},
|
|
||||||
{"name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3},
|
|
||||||
{"name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3},
|
|
||||||
{"name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_feeds() -> list[dict]:
|
|
||||||
"""Load feeds from config file, falling back to defaults."""
|
|
||||||
try:
|
|
||||||
if CONFIG_PATH.exists():
|
|
||||||
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
||||||
feeds = data.get("feeds", []) if isinstance(data, dict) else data
|
|
||||||
if isinstance(feeds, list) and len(feeds) > 0:
|
|
||||||
return feeds
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to read news feed config: {e}")
|
|
||||||
return list(DEFAULT_FEEDS)
|
|
||||||
|
|
||||||
|
|
||||||
def save_feeds(feeds: list[dict]) -> bool:
|
|
||||||
"""Validate and save feeds to config file. Returns True on success."""
|
|
||||||
if not isinstance(feeds, list):
|
|
||||||
return False
|
|
||||||
if len(feeds) > MAX_FEEDS:
|
|
||||||
return False
|
|
||||||
# Validate each feed entry
|
|
||||||
for f in feeds:
|
|
||||||
if not isinstance(f, dict):
|
|
||||||
return False
|
|
||||||
name = f.get("name", "").strip()
|
|
||||||
url = f.get("url", "").strip()
|
|
||||||
weight = f.get("weight", 3)
|
|
||||||
if not name or not url:
|
|
||||||
return False
|
|
||||||
if not isinstance(weight, (int, float)) or weight < 1 or weight > 5:
|
|
||||||
return False
|
|
||||||
# Normalise
|
|
||||||
f["name"] = name
|
|
||||||
f["url"] = url
|
|
||||||
f["weight"] = int(weight)
|
|
||||||
try:
|
|
||||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
CONFIG_PATH.write_text(
|
|
||||||
json.dumps({"feeds": feeds}, indent=2, ensure_ascii=False),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to write news feed config: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def reset_feeds() -> bool:
|
|
||||||
"""Reset feeds to defaults."""
|
|
||||||
return save_feeds(list(DEFAULT_FEEDS))
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"""
|
|
||||||
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)}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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))
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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)}")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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]}")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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}")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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])
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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())}")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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', []))}")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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}")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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}")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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')}")
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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}")
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""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", "-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", "-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", "-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", "-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]}")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
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}")
|
||||||
-116
@@ -1,116 +0,0 @@
|
|||||||
#!/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[@]}"
|
|
||||||
+6
-9
@@ -8,12 +8,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- AIS_API_KEY=${AIS_API_KEY}
|
- AISSTREAM_API_KEY=${AISSTREAM_API_KEY}
|
||||||
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
|
- N2YO_API_KEY=${N2YO_API_KEY}
|
||||||
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
|
- OPENSKY_USERNAME=${OPENSKY_USERNAME}
|
||||||
|
- OPENSKY_PASSWORD=${OPENSKY_PASSWORD}
|
||||||
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
|
||||||
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
|
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -21,13 +20,11 @@ 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
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
eslint.config.mjs
|
eslint.config.mjs
|
||||||
node_modules
|
postcss.config.mjs
|
||||||
npm-debug.log*
|
tailwind.config.ts
|
||||||
build_logs*.txt
|
|
||||||
build_output.txt
|
|
||||||
build_error.txt
|
|
||||||
errors.txt
|
|
||||||
server_logs*.txt
|
|
||||||
|
|||||||
+1
-5
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS base
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
@@ -11,10 +11,6 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
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
|
RUN npm run build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|||||||
+21
-36
@@ -1,51 +1,36 @@
|
|||||||
# ShadowBroker Frontend
|
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).
|
||||||
|
|
||||||
Next.js 16 dashboard with MapLibre GL, Cesium, and Framer Motion.
|
## Getting Started
|
||||||
|
|
||||||
## Development
|
First, run the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm run dev
|
||||||
npm run dev # http://localhost:3000
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## API URL Configuration
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
The frontend needs to reach the backend (default port `8000`). Resolution order:
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
1. **`NEXT_PUBLIC_API_URL`** env var — if set, used as-is (build-time, baked by Next.js)
|
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.
|
||||||
2. **Server-side (SSR)** — falls back to `http://localhost:8000`
|
|
||||||
3. **Client-side (browser)** — auto-detects using `window.location.hostname:8000`
|
|
||||||
|
|
||||||
### Common scenarios
|
## Learn More
|
||||||
|
|
||||||
| Scenario | Action needed |
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|----------|---------------|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
### Setting the variable
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
```bash
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
# Shell (Linux/macOS)
|
|
||||||
NEXT_PUBLIC_API_URL=http://myserver:8000 npm run build
|
|
||||||
|
|
||||||
# PowerShell (Windows)
|
## Deploy on Vercel
|
||||||
$env:NEXT_PUBLIC_API_URL="http://myserver:8000"; npm run build
|
|
||||||
|
|
||||||
# Docker Compose (set in .env file next to docker-compose.yml)
|
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.
|
||||||
NEXT_PUBLIC_API_URL=http://myserver:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** This is a build-time variable. Changing it requires rebuilding the frontend.
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
|
|
||||||
> 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
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#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
|
|
||||||
Binary file not shown.
Generated
+165
-74
@@ -1,21 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.3.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.3.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/point-geometry": "^1.1.0",
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"@types/mapbox-gl": "^3.4.1",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"hls.js": "^1.6.15",
|
"leaflet": "^1.9.4",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -1051,6 +1054,12 @@
|
|||||||
"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",
|
||||||
@@ -1069,6 +1078,17 @@
|
|||||||
"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",
|
||||||
@@ -1283,6 +1303,17 @@
|
|||||||
"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",
|
||||||
@@ -1522,70 +1553,6 @@
|
|||||||
"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",
|
||||||
@@ -1681,6 +1648,15 @@
|
|||||||
"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",
|
||||||
@@ -1702,6 +1678,15 @@
|
|||||||
"@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",
|
||||||
@@ -2878,6 +2863,12 @@
|
|||||||
"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",
|
||||||
@@ -2989,6 +2980,12 @@
|
|||||||
"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",
|
||||||
@@ -4275,6 +4272,12 @@
|
|||||||
"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",
|
||||||
@@ -4386,12 +4389,6 @@
|
|||||||
"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",
|
||||||
@@ -5104,6 +5101,12 @@
|
|||||||
"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",
|
||||||
@@ -5444,6 +5447,45 @@
|
|||||||
"@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",
|
||||||
@@ -5545,6 +5587,17 @@
|
|||||||
"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",
|
||||||
@@ -6008,6 +6061,18 @@
|
|||||||
"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",
|
||||||
@@ -6165,6 +6230,20 @@
|
|||||||
"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",
|
||||||
@@ -6304,6 +6383,12 @@
|
|||||||
"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",
|
||||||
@@ -6699,6 +6784,12 @@
|
|||||||
"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,24 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.5.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
||||||
"dev:frontend": "next dev",
|
"dev:frontend": "next dev",
|
||||||
"dev:backend": "node ../start-backend.js",
|
"dev:backend": "cd ../backend && python -m uvicorn main:app --reload",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/point-geometry": "^1.1.0",
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"@types/mapbox-gl": "^3.4.1",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"hls.js": "^1.6.15",
|
"leaflet": "^1.9.4",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -34,4 +38,4 @@
|
|||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,40 +1,8 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #000000;
|
--background: #ffffff;
|
||||||
--foreground: #ededed;
|
--foreground: #171717;
|
||||||
--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 {
|
||||||
@@ -44,6 +12,13 @@
|
|||||||
--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);
|
||||||
@@ -60,12 +35,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.styled-scrollbar::-webkit-scrollbar-thumb {
|
.styled-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: var(--scrollbar-thumb);
|
background: rgba(100, 116, 139, 0.3);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--scrollbar-thumb-hover);
|
background: rgba(100, 116, 139, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.styled-scrollbar {
|
.styled-scrollbar {
|
||||||
@@ -95,4 +70,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,6 +1,5 @@
|
|||||||
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({
|
||||||
@@ -30,10 +29,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-[var(--bg-primary)]`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-black`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+27
-199
@@ -1,6 +1,5 @@
|
|||||||
"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";
|
||||||
@@ -15,106 +14,10 @@ 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);
|
||||||
@@ -143,36 +46,19 @@ 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,
|
|
||||||
firms: false,
|
|
||||||
internet_outages: false,
|
|
||||||
datacenters: 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', 'SATELLITE'];
|
const stylesList = ['DEFAULT', 'FLIR', 'NVG', 'CRT'];
|
||||||
|
|
||||||
const cycleStyle = () => {
|
const cycleStyle = () => {
|
||||||
setActiveStyle((prev) => {
|
setActiveStyle((prev) => {
|
||||||
const idx = stylesList.indexOf(prev);
|
const idx = stylesList.indexOf(prev);
|
||||||
const next = stylesList[(idx + 1) % stylesList.length];
|
return stylesList[(idx + 1) % stylesList.length];
|
||||||
// Auto-toggle High-Res Satellite layer with SATELLITE style
|
|
||||||
setActiveLayers((l: any) => ({ ...l, highres_satellite: next === 'SATELLITE' }));
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,11 +74,6 @@ 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);
|
||||||
|
|
||||||
@@ -265,19 +146,11 @@ export default function Dashboard() {
|
|||||||
setRegionDossierLoading(true);
|
setRegionDossierLoading(true);
|
||||||
setRegionDossier(null);
|
setRegionDossier(null);
|
||||||
try {
|
try {
|
||||||
const [dossierRes, sentinelRes] = await Promise.allSettled([
|
const res = await fetch(`http://localhost:8000/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`);
|
||||||
fetch(`${API_BASE}/api/region-dossier?lat=${coords.lat}&lng=${coords.lng}`),
|
if (res.ok) {
|
||||||
fetch(`${API_BASE}/api/sentinel2/search?lat=${coords.lat}&lng=${coords.lng}`),
|
const data = await res.json();
|
||||||
]);
|
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 {
|
||||||
@@ -302,10 +175,9 @@ 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(`${API_BASE}/api/live-data/fast`, { headers });
|
const res = await fetch("http://localhost:8000/api/live-data/fast", { headers });
|
||||||
if (res.status === 304) { setBackendStatus('connected'); return; }
|
if (res.status === 304) return; // Data unchanged, skip update
|
||||||
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 };
|
||||||
@@ -313,7 +185,6 @@ 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');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,7 +192,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(`${API_BASE}/api/live-data/slow`, { headers });
|
const res = await fetch("http://localhost:8000/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;
|
||||||
@@ -337,10 +208,10 @@ export default function Dashboard() {
|
|||||||
fetchFastData();
|
fetchFastData();
|
||||||
fetchSlowData();
|
fetchSlowData();
|
||||||
|
|
||||||
// Fast polling: 60s (matches backend update cadence — was 15s, wasting 75% on 304s)
|
// Fast polling: 15s (backend updates every 60s — polling more often just yields 304s)
|
||||||
// Slow polling: 120s (backend updates every 30min)
|
// Slow polling: 60s (backend updates every 30min)
|
||||||
const fastInterval = setInterval(fetchFastData, 60000);
|
const fastInterval = setInterval(fetchFastData, 15000);
|
||||||
const slowInterval = setInterval(fetchSlowData, 120000);
|
const slowInterval = setInterval(fetchSlowData, 60000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(fastInterval);
|
clearInterval(fastInterval);
|
||||||
@@ -349,7 +220,7 @@ export default function Dashboard() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="fixed inset-0 w-full h-full bg-[var(--bg-primary)] overflow-hidden font-sans">
|
<main className="fixed inset-0 w-full h-full bg-black overflow-hidden font-sans">
|
||||||
|
|
||||||
{/* MAPLIBRE WEBGL OVERLAY */}
|
{/* MAPLIBRE WEBGL OVERLAY */}
|
||||||
<ErrorBoundary name="Map">
|
<ErrorBoundary name="Map">
|
||||||
@@ -361,8 +232,6 @@ 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}
|
||||||
@@ -397,10 +266,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-[var(--text-primary)] flex items-center gap-3" style={{ fontFamily: 'monospace' }}>
|
<h1 className="text-2xl font-bold tracking-[0.4em] text-white flex items-center gap-3" style={{ fontFamily: 'monospace' }}>
|
||||||
S H A D O W <span className="text-cyan-400">B R O K E R</span>
|
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-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">GLOBAL THREAT INTERCEPT</span>
|
<span className="text-[9px] text-gray-500 font-mono tracking-[0.3em] mt-1 ml-1">GLOBAL THREAT INTERCEPT</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -410,7 +279,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-[var(--text-muted)] z-[200] pointer-events-none">
|
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-gray-600 z-[200] pointer-events-none">
|
||||||
<div>RTX</div>
|
<div>RTX</div>
|
||||||
<div>VSR</div>
|
<div>VSR</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +287,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)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} />
|
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} />
|
||||||
|
|
||||||
{/* LEFT BOTTOM - DISPLAY CONFIG */}
|
{/* LEFT BOTTOM - DISPLAY CONFIG */}
|
||||||
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
|
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
|
||||||
@@ -458,7 +327,6 @@ export default function Dashboard() {
|
|||||||
setIsEavesdropping={setIsEavesdropping}
|
setIsEavesdropping={setIsEavesdropping}
|
||||||
eavesdropLocation={eavesdropLocation}
|
eavesdropLocation={eavesdropLocation}
|
||||||
cameraCenter={cameraCenter}
|
cameraCenter={cameraCenter}
|
||||||
selectedEntity={selectedEntity}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -478,57 +346,39 @@ 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 flex flex-col items-center gap-2"
|
className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-auto"
|
||||||
>
|
>
|
||||||
{/* LOCATE BAR — search by coordinates or place name */}
|
|
||||||
<LocateBar onLocate={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
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"
|
className="bg-black/60 backdrop-blur-md border border-gray-800 rounded-xl px-6 py-2.5 flex items-center gap-6 shadow-[0_4px_30px_rgba(0,0,0,0.5)] border-b-2 border-b-cyan-900 cursor-pointer"
|
||||||
onClick={cycleStyle}
|
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-[var(--text-muted)] font-mono tracking-[0.2em]">COORDINATES</div>
|
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">COORDINATES</div>
|
||||||
<div className="text-[11px] text-cyan-400 font-mono font-bold tracking-wide">
|
<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-[var(--border-primary)]" />
|
<div className="w-px h-8 bg-gray-700" />
|
||||||
|
|
||||||
{/* 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-[var(--text-muted)] font-mono tracking-[0.2em]">LOCATION</div>
|
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">LOCATION</div>
|
||||||
<div className="text-[10px] text-[var(--text-secondary)] font-mono truncate max-w-[320px]">
|
<div className="text-[10px] text-gray-300 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-[var(--border-primary)]" />
|
<div className="w-px h-8 bg-gray-700" />
|
||||||
|
|
||||||
{/* 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-[var(--text-muted)] font-mono tracking-[0.2em]">STYLE</div>
|
<div className="text-[8px] text-gray-600 font-mono tracking-[0.2em]">STYLE</div>
|
||||||
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
|
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-px h-8 bg-[var(--border-primary)]" />
|
|
||||||
|
|
||||||
{/* Space Weather */}
|
|
||||||
<div className="flex flex-col items-center" title={`Kp Index: ${data?.space_weather?.kp_index ?? 'N/A'}`}>
|
|
||||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">SOLAR</div>
|
|
||||||
<div className={`text-[11px] font-mono font-bold ${
|
|
||||||
(data?.space_weather?.kp_index ?? 0) >= 5 ? 'text-red-400' :
|
|
||||||
(data?.space_weather?.kp_index ?? 0) >= 4 ? 'text-yellow-400' :
|
|
||||||
'text-green-400'
|
|
||||||
}`}>
|
|
||||||
{data?.space_weather?.kp_text || 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
@@ -538,7 +388,7 @@ export default function Dashboard() {
|
|||||||
{!uiVisible && (
|
{!uiVisible && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setUiVisible(true)}
|
onClick={() => setUiVisible(true)}
|
||||||
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"
|
className="absolute bottom-6 right-6 z-[200] bg-black/60 backdrop-blur-md border border-gray-800 rounded px-4 py-2 text-[10px] font-mono tracking-widest text-cyan-500 hover:text-cyan-300 hover:border-cyan-800 transition-colors pointer-events-auto"
|
||||||
>
|
>
|
||||||
RESTORE UI
|
RESTORE UI
|
||||||
</button>
|
</button>
|
||||||
@@ -575,28 +425,6 @@ 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-[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`}
|
className={`bg-[#0a0e14]/95 backdrop-blur-xl border ${c.border} rounded-xl shadow-[0_8px_60px_rgba(0,0,0,0.8)] flex flex-col font-mono overflow-hidden`}
|
||||||
style={{ maxHeight: '70vh' }}
|
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-[var(--border-primary)]/60 select-none flex-shrink-0"
|
className="flex items-center justify-between px-4 py-3 cursor-grab active:cursor-grabbing border-b border-gray-800/60 select-none flex-shrink-0"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<GripHorizontal size={14} className="text-[var(--text-muted)]" />
|
<GripHorizontal size={14} className="text-gray-600" />
|
||||||
{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-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors p-1 rounded hover:bg-[var(--bg-tertiary)]">
|
<button onClick={onClose} className="text-gray-600 hover:text-white transition-colors p-1 rounded hover:bg-gray-800">
|
||||||
<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-[var(--border-primary)]/40 px-3 pt-2 gap-1 flex-shrink-0">
|
<div className="flex border-b border-gray-800/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-[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`}
|
className={`w-full bg-black/50 border border-gray-700/70 rounded-lg text-[11px] text-gray-300 pl-8 pr-8 py-2 font-mono tracking-wide focus:outline-none focus:${c.border} focus:ring-1 ${c.ring} placeholder-gray-600 transition-all`}
|
||||||
autoFocus
|
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-[var(--text-muted)] tracking-widest">
|
<span className="text-[8px] text-gray-600 tracking-widest">
|
||||||
{filteredOptions.length} AVAILABLE
|
{filteredOptions.length} AVAILABLE
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
|
<span className="text-[8px] text-gray-600 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-[var(--text-muted)] text-[10px] tracking-widest">
|
<div className="text-center py-8 text-gray-600 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-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]/50 hover:text-[var(--text-primary)]`
|
: `text-gray-400 hover:bg-gray-800/50 hover:text-gray-200`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 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-[var(--border-primary)] group-hover:border-[var(--border-secondary)]'
|
: 'border-gray-700 group-hover:border-gray-500'
|
||||||
}`}>
|
}`}>
|
||||||
{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-[var(--border-primary)]/60 flex-shrink-0">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800/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-[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"
|
className="text-[9px] text-gray-500 hover:text-gray-300 tracking-widest border border-gray-700 rounded-md px-4 py-1.5 hover:bg-gray-800/50 transition-all"
|
||||||
>
|
>
|
||||||
CANCEL
|
CANCEL
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { X, Rss, Server, Zap, Shield, Bug } from "lucide-react";
|
|
||||||
|
|
||||||
const CURRENT_VERSION = "0.6";
|
|
||||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
|
||||||
|
|
||||||
const NEW_FEATURES = [
|
|
||||||
{
|
|
||||||
icon: <Rss size={14} className="text-orange-400" />,
|
|
||||||
title: "Custom News Feed Manager",
|
|
||||||
desc: "Add, remove, and prioritize up to 20 RSS intelligence sources directly from the Settings panel. Assign weight levels (1-5) to control feed importance. No more editing Python files — your custom feeds persist across restarts.",
|
|
||||||
color: "orange",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Server size={14} className="text-purple-400" />,
|
|
||||||
title: "Global Data Center Map Layer",
|
|
||||||
desc: "2,000+ data centers plotted worldwide from a curated dataset. Click any DC for operator details — and if an internet outage is detected in the same country, the popup flags it automatically.",
|
|
||||||
color: "purple",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Zap size={14} className="text-yellow-400" />,
|
|
||||||
title: "Imperative Map Rendering",
|
|
||||||
desc: "High-volume layers (flights, satellites, fire hotspots) now bypass React reconciliation and update the map directly via setData(). Debounced updates on dense layers. Smoother panning and zooming under load.",
|
|
||||||
color: "yellow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Shield size={14} className="text-cyan-400" />,
|
|
||||||
title: "Enhanced Health Observability",
|
|
||||||
desc: "The /api/health endpoint now reports per-source freshness timestamps and counts for all data layers — UAVs, FIRMS fires, LiveUAMap, GDELT, and more. Better uptime monitoring for self-hosters.",
|
|
||||||
color: "cyan",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const BUG_FIXES = [
|
|
||||||
"Settings panel now has tabbed UI — API Keys and News Feeds on separate tabs",
|
|
||||||
"Data center coordinates fixed for 187 Southern Hemisphere entries (were mirrored north of equator)",
|
|
||||||
"Docker networking: CORS_ORIGINS env var properly passed through docker-compose",
|
|
||||||
"Start scripts warn on Python 3.13+ compatibility issues before install",
|
|
||||||
"Satellite and fire hotspot layers debounced (2s) to prevent render thrashing",
|
|
||||||
"Entries with invalid geocoded coordinates automatically filtered out",
|
|
||||||
];
|
|
||||||
|
|
||||||
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-[var(--text-secondary)] text-[10px]">{this.props.name || "Component"} failed to render</div>
|
<div className="text-gray-400 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-[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"
|
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
|
||||||
>
|
>
|
||||||
{/* Header Toggle */}
|
{/* Header Toggle */}
|
||||||
<div
|
<div
|
||||||
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"
|
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||||
onClick={() => setIsMinimized(!isMinimized)}
|
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-[var(--text-muted)] font-mono tracking-widest">DATA FILTERS</span>
|
<span className="text-[10px] text-gray-500 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-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
<button className="text-gray-500 hover:text-white 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-[var(--border-primary)]'} hover:bg-[var(--bg-primary)]/30`}
|
className={`border rounded-lg transition-all cursor-pointer group ${borderColors[section.color] || 'border-gray-800'} hover:bg-black/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-[var(--text-secondary)] tracking-widest group-hover:text-[var(--text-primary)] transition-colors">{section.title}</span>
|
<span className="text-[9px] text-gray-400 tracking-widest group-hover:text-gray-200 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-[var(--text-muted)] group-hover:text-[var(--text-secondary)] transition-colors" />
|
<SlidersHorizontal size={10} className="text-gray-600 group-hover:text-gray-400 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-[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">
|
<div className="flex items-center gap-2 bg-black/40 backdrop-blur-md border border-gray-800 rounded-lg px-3 py-2 focus-within:border-cyan-500/40 transition-colors">
|
||||||
<Search size={12} className="text-[var(--text-muted)] flex-shrink-0" />
|
<Search size={12} className="text-gray-500 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-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
|
className="flex-1 bg-transparent text-[10px] text-gray-300 font-mono tracking-wider outline-none placeholder:text-gray-600"
|
||||||
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-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
<button onClick={() => { setQuery(""); setIsOpen(false); }} className="text-gray-600 hover:text-white transition-colors">
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Crosshair size={12} className="text-[var(--text-muted)] flex-shrink-0" />
|
<Crosshair size={12} className="text-gray-600 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-[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)]"
|
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg overflow-hidden z-50 shadow-[0_8px_30px_rgba(0,0,0,0.6)]"
|
||||||
>
|
>
|
||||||
<div className="max-h-[300px] overflow-y-auto styled-scrollbar">
|
<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-[var(--hover-accent)] transition-colors text-left border-b border-[var(--border-primary)]/50 last:border-0 group"
|
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-cyan-950/30 transition-colors text-left border-b border-gray-800/50 last:border-0 group"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded bg-[var(--bg-secondary)] border border-[var(--border-primary)] group-hover:border-cyan-800">
|
<div className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded bg-gray-900 border border-gray-800 group-hover:border-cyan-800">
|
||||||
{categoryIcons[r.category]}
|
{categoryIcons[r.category]}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[10px] text-[var(--text-primary)] font-mono tracking-wide truncate">{r.label}</div>
|
<div className="text-[10px] text-gray-200 font-mono tracking-wide truncate">{r.label}</div>
|
||||||
<div className="text-[8px] text-[var(--text-muted)] font-mono truncate">{r.sublabel}</div>
|
<div className="text-[8px] text-gray-500 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-[var(--border-primary)] bg-[var(--bg-primary)]/50 text-[8px] text-[var(--text-muted)] font-mono tracking-widest">
|
<div className="px-3 py-1.5 border-t border-gray-800 bg-black/50 text-[8px] text-gray-600 font-mono tracking-widest">
|
||||||
{filtered.length} RESULT{filtered.length !== 1 ? 'S' : ''} — CLICK TO LOCATE
|
{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-[var(--bg-secondary)]/90 backdrop-blur-md border border-[var(--border-primary)] rounded-lg z-50 p-4 text-center"
|
className="absolute top-full left-0 right-0 mt-1 bg-black/90 backdrop-blur-md border border-gray-800 rounded-lg z-50 p-4 text-center"
|
||||||
>
|
>
|
||||||
<div className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">NO MATCHING ASSETS</div>
|
<div className="text-[9px] text-gray-600 font-mono tracking-widest">NO MATCHING ASSETS</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap, Tooltip, CircleMarker, useMapEvents } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Fix standard leaflet icon path issues in React
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create custom icons dynamically for the layers
|
||||||
|
const createDivIcon = (svg: string, size = 16, rotate = 0) => {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'custom-div-icon',
|
||||||
|
html: `<div style="transform: rotate(${rotate}deg); width: ${size}px; height: ${size}px;">${svg}</div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const svgPlaneCyan = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00d4ff"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`;
|
||||||
|
const svgPlaneOrange = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffaa00"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`;
|
||||||
|
const svgPlaneRed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff3333"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`;
|
||||||
|
const svgShip = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#888888"><path d="M12 22V8" /><path d="M5 12H19" /><path d="M9 22H15" /><circle cx="12" cy="5" r="3" /><path d="M12 22C8 22 4 19 4 15V13M12 22C16 22 20 19 20 15V13" /></svg>`;
|
||||||
|
const svgThreat = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffff00" stroke="#ff0000" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>`;
|
||||||
|
const svgTriangleYellow = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffaa00" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||||
|
const svgTriangleRed = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ff0000" stroke="#fff" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`;
|
||||||
|
|
||||||
|
// Helper component to center map when Find Locater is used
|
||||||
|
function MapCenterControl({ location }: { location: { lat: number, lng: number } | null }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (location) {
|
||||||
|
map.flyTo([location.lat, location.lng], 8, { duration: 1.5 });
|
||||||
|
}
|
||||||
|
}, [location, map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eavesdrop mode controller
|
||||||
|
function ClickHandler({ isEavesdropping, onEavesdropClick }: any) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEavesdropping) return;
|
||||||
|
const cb = (e: any) => onEavesdropClick({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||||
|
map.on('click', cb);
|
||||||
|
return () => { map.off('click', cb); };
|
||||||
|
}, [isEavesdropping, map, onEavesdropClick]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map state tracker for LOD
|
||||||
|
function MapStateTracker({ onStateChange }: { onStateChange: (zoom: number, bounds: L.LatLngBounds) => void }) {
|
||||||
|
const map = useMapEvents({
|
||||||
|
moveend: () => onStateChange(map.getZoom(), map.getBounds()),
|
||||||
|
zoomend: () => onStateChange(map.getZoom(), map.getBounds()),
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
onStateChange(map.getZoom(), map.getBounds());
|
||||||
|
}, [map, onStateChange]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeafletViewer({ data, activeLayers, activeFilters, effects, onEntityClick, selectedEntity, flyToLocation, isEavesdropping, onEavesdropClick, onCameraMove }: any) {
|
||||||
|
const [zoom, setZoom] = useState(3);
|
||||||
|
const [bounds, setBounds] = useState<L.LatLngBounds | null>(null);
|
||||||
|
|
||||||
|
const handleMapState = (z: number, b: L.LatLngBounds) => {
|
||||||
|
setZoom(z);
|
||||||
|
setBounds(b);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isVisible = (lat: number, lng: number) => {
|
||||||
|
if (!bounds) return true;
|
||||||
|
return bounds.pad(0.2).contains([lat, lng]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100vw", height: "100vh", position: "fixed", top: 0, left: 0, zIndex: 0, background: "black" }}>
|
||||||
|
<MapContainer
|
||||||
|
center={[20, 0]}
|
||||||
|
zoom={3}
|
||||||
|
style={{ width: "100%", height: "100%", background: "#06080a" }} // Extremely dark ocean base
|
||||||
|
zoomControl={false}
|
||||||
|
minZoom={2}
|
||||||
|
maxZoom={12}
|
||||||
|
>
|
||||||
|
<MapStateTracker onStateChange={handleMapState} />
|
||||||
|
<MapCenterControl location={flyToLocation} />
|
||||||
|
<ClickHandler isEavesdropping={isEavesdropping} onEavesdropClick={onEavesdropClick} />
|
||||||
|
|
||||||
|
{/* Dark Mode Satellite Stamen / CartoDB Voyager basemap substitute */}
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
|
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||||
|
maxZoom={19}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* --- COMMERCIAL FLIGHTS --- */}
|
||||||
|
{activeLayers.flights && data?.commercial_flights?.map((f: any, idx: number) => {
|
||||||
|
if (f.lat == null || f.lng == null) return null;
|
||||||
|
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||||
|
|
||||||
|
if (zoom < 6) {
|
||||||
|
return (
|
||||||
|
<CircleMarker
|
||||||
|
key={`comm-${idx}`}
|
||||||
|
center={[f.lat, f.lng]}
|
||||||
|
radius={2}
|
||||||
|
pathOptions={{ color: '#00d4ff', fillColor: '#00d4ff', fillOpacity: 0.8, weight: 1, stroke: false }}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = createDivIcon(svgPlaneCyan, 18, f.true_track || f.heading || 0);
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={`comm-${idx}`}
|
||||||
|
position={[f.lat, f.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||||
|
<div className="text-cyan-400 font-bold bg-black px-1 text-xs border border-cyan-500/50">{f.callsign || f.icao24}</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* --- PRIVATE FLIGHTS --- */}
|
||||||
|
{activeLayers.private && data?.private_flights?.map((f: any, idx: number) => {
|
||||||
|
if (f.lat == null || f.lng == null) return null;
|
||||||
|
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||||
|
|
||||||
|
if (zoom < 6) {
|
||||||
|
return (
|
||||||
|
<CircleMarker
|
||||||
|
key={`priv-${idx}`}
|
||||||
|
center={[f.lat, f.lng]}
|
||||||
|
radius={2}
|
||||||
|
pathOptions={{ color: '#ffaa00', fillColor: '#ffaa00', fillOpacity: 0.8, weight: 1, stroke: false }}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'private_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = createDivIcon(svgPlaneOrange, 18, f.true_track || f.heading || 0);
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={`priv-${idx}`}
|
||||||
|
position={[f.lat, f.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'private_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||||
|
<div className="text-orange-400 font-bold bg-black px-1 text-xs border border-orange-500/50">{f.callsign || f.icao24}</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* --- MILITARY FLIGHTS --- */}
|
||||||
|
{activeLayers.military && data?.military_flights?.map((f: any, idx: number) => {
|
||||||
|
if (f.lat == null || f.lng == null) return null;
|
||||||
|
if (zoom >= 6 && !isVisible(f.lat, f.lng)) return null;
|
||||||
|
|
||||||
|
if (zoom < 6) {
|
||||||
|
return (
|
||||||
|
<CircleMarker
|
||||||
|
key={`mil-${idx}`}
|
||||||
|
center={[f.lat, f.lng]}
|
||||||
|
radius={3}
|
||||||
|
pathOptions={{ color: '#ff3333', fillColor: '#ff3333', fillOpacity: 0.9, weight: 1, stroke: false }}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'military_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = createDivIcon(svgPlaneRed, 20, f.true_track || f.heading || 0);
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={`mil-${idx}`}
|
||||||
|
position={[f.lat, f.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'military_flight', id: idx, callsign: f.callsign || f.icao24 }) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -10]} opacity={0.9}>
|
||||||
|
<div className="text-red-500 font-bold bg-black px-1 text-xs border border-red-500/50">{f.callsign || f.icao24}</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* --- SHIPS --- */}
|
||||||
|
{(activeLayers.ships_important || activeLayers.ships_civilian || activeLayers.ships_passenger) && data?.ships?.map((s: any, idx: number) => {
|
||||||
|
if (s.lat == null || s.lng == null) return null;
|
||||||
|
if (zoom >= 6 && !isVisible(s.lat, s.lng)) return null;
|
||||||
|
|
||||||
|
if (zoom < 6) {
|
||||||
|
return (
|
||||||
|
<CircleMarker
|
||||||
|
key={`ship-${idx}`}
|
||||||
|
center={[s.lat, s.lng]}
|
||||||
|
radius={1.5}
|
||||||
|
pathOptions={{ color: '#888888', fillColor: '#888888', fillOpacity: 0.6, weight: 0.5, stroke: false }}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'ship', id: idx, name: s.name }) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = createDivIcon(svgShip, 12, s.heading || 0);
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={`ship-${idx}`}
|
||||||
|
position={[s.lat, s.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'ship', id: idx, name: s.name }) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -5]} opacity={0.8}>
|
||||||
|
<div className="text-gray-300 font-bold bg-black px-1 text-[10px] border border-gray-600/50">{s.name}</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* --- GDELT GLOBAL INCIDENTS --- */}
|
||||||
|
{activeLayers.global_incidents && data?.gdelt?.map((incident: any, idx: number) => {
|
||||||
|
const geom = incident.geometry;
|
||||||
|
if (!geom || geom.type !== 'Point' || !geom.coordinates) return null;
|
||||||
|
const lng = geom.coordinates[0];
|
||||||
|
const lat = geom.coordinates[1];
|
||||||
|
if (!isVisible(lat, lng)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CircleMarker
|
||||||
|
key={`gdelt-${idx}`}
|
||||||
|
center={[geom.coordinates[1], geom.coordinates[0]]}
|
||||||
|
radius={8}
|
||||||
|
pathOptions={{ color: '#ff0000', fillColor: '#ff8c00', fillOpacity: 0.6, weight: 2 }}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'gdelt', id: idx }) }}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<div className="text-orange-500 text-xs bg-black p-1 max-w-[200px] whitespace-normal">
|
||||||
|
{incident.title}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</CircleMarker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* --- LIVEUAMAP INCIDENTS --- */}
|
||||||
|
{activeLayers.global_incidents && data?.liveuamap?.map((incident: any, idx: number) => {
|
||||||
|
if (incident.lat == null || incident.lng == null) return null;
|
||||||
|
if (!isVisible(incident.lat, incident.lng)) return null;
|
||||||
|
const isViolent = /bomb|missil|strike|attack|kill|destroy|fire|shoot|expl|raid/i.test(incident.title || "");
|
||||||
|
const icon = createDivIcon(isViolent ? svgTriangleRed : svgTriangleYellow, 18);
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={`liveua-${idx}`}
|
||||||
|
position={[incident.lat, incident.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'liveuamap', id: incident.id, title: incident.title }) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -10]} opacity={0.95}>
|
||||||
|
<div className="text-white font-bold bg-black p-1 text-[11px] border border-gray-600 max-w-[200px] whitespace-normal">
|
||||||
|
<span className={isViolent ? "text-red-500" : "text-yellow-500"}>[LIVEUA]</span> {incident.title}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* --- RSS THREAT ALERTS --- */}
|
||||||
|
{activeLayers.global_incidents && data?.news?.filter((n: any) => n.coordinates)?.map((n: any, idx: number) => {
|
||||||
|
if (n.coordinates.lat == null || n.coordinates.lng == null) return null;
|
||||||
|
if (!isVisible(n.coordinates.lat, n.coordinates.lng)) return null;
|
||||||
|
const icon = createDivIcon(svgThreat, 24);
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={`threat-${idx}`}
|
||||||
|
position={[n.coordinates.lat, n.coordinates.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{ click: () => onEntityClick?.({ type: 'news', id: idx }) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -12]} opacity={1.0} permanent={true} className="bg-transparent border-0 shadow-none">
|
||||||
|
<div className="text-red-500 font-bold bg-black/80 px-2 py-1 text-[10px] border border-red-500/50 backdrop-blur" style={{ textShadow: "0px 0px 4px #000" }}>
|
||||||
|
!! LVL {n.threat_level} !!<br />
|
||||||
|
<span className="text-yellow-400 font-normal">{n.title.substring(0, 30)}...</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -94,7 +94,8 @@ const LEGEND: LegendCategory[] = [
|
|||||||
{ svg: airliner("yellow"), label: "Military — Standard" },
|
{ svg: airliner("yellow"), label: "Military — Standard" },
|
||||||
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
{ svg: plane("yellow"), label: "Fighter / Interceptor" },
|
||||||
{ svg: heli("yellow"), label: "Military — Helicopter" },
|
{ svg: heli("yellow"), label: "Military — Helicopter" },
|
||||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone (live ADS-B)" },
|
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone" },
|
||||||
|
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="orange" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.6"/><circle cx="12" cy="12" r="2" fill="orange"/></svg>`, label: "UAV Operational Range (dashed circle)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -216,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-[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)]"
|
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] max-h-[80vh] bg-gray-950/95 backdrop-blur-xl border border-cyan-900/50 rounded-xl z-[9999] flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.8)]"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-5 border-b border-[var(--border-primary)]/80 flex-shrink-0">
|
<div className="flex items-center justify-between p-5 border-b border-gray-800/80 flex-shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="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">
|
||||||
@@ -229,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-[var(--text-primary)] font-mono">MAP LEGEND</h2>
|
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">MAP LEGEND</h2>
|
||||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">ICON REFERENCE KEY</span>
|
<span className="text-[9px] text-gray-500 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-[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"
|
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -246,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-[var(--border-primary)]/60 overflow-hidden">
|
<div key={cat.name} className="rounded-lg border border-gray-800/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-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
className="w-full flex items-center justify-between px-3 py-2 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
|
||||||
>
|
>
|
||||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${cat.color}`}>
|
<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-[var(--text-muted)]" /> : <ChevronUp size={12} className="text-[var(--text-muted)]" />}
|
{isCollapsed ? <ChevronDown size={12} className="text-gray-500" /> : <ChevronUp size={12} className="text-gray-500" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Items */}
|
{/* Items */}
|
||||||
@@ -266,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-[var(--border-primary)]/40"
|
className="border-t border-gray-800/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-[var(--bg-secondary)]/30 transition-colors">
|
<div key={idx} className="flex items-center gap-3 px-4 py-1.5 hover:bg-gray-900/30 transition-colors">
|
||||||
<IconImg svg={item.svg} />
|
<IconImg svg={item.svg} />
|
||||||
<span className="text-[11px] text-[var(--text-secondary)] font-mono">{item.label}</span>
|
<span className="text-[11px] text-gray-300 font-mono">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,8 +286,8 @@ const MapLegend = React.memo(function MapLegend({ isOpen, onClose }: { isOpen: b
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-3 border-t border-[var(--border-primary)]/80 flex-shrink-0">
|
<div className="p-3 border-t border-gray-800/80 flex-shrink-0">
|
||||||
<div className="text-[9px] text-[var(--text-muted)] font-mono text-center tracking-wider">
|
<div className="text-[9px] text-gray-600 font-mono text-center tracking-wider">
|
||||||
{LEGEND.reduce((sum, c) => sum + c.items.length, 0)} ICON DEFINITIONS ACROSS {LEGEND.length} CATEGORIES
|
{LEGEND.reduce((sum, c) => sum + c.items.length, 0)} ICON DEFINITIONS ACROSS {LEGEND.length} CATEGORIES
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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-[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"
|
className="w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono text-sm shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto flex-shrink-0"
|
||||||
>
|
>
|
||||||
{/* Header Toggle */}
|
{/* Header Toggle */}
|
||||||
<div
|
<div
|
||||||
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"
|
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||||
onClick={() => setIsMinimized(!isMinimized)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
|
<span className="text-[10px] text-gray-500 font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
<button className="text-gray-500 hover:text-white 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-[var(--border-primary)] pb-3">
|
<div className="border-b border-gray-800 pb-3">
|
||||||
<h2 className="text-xs font-bold tracking-widest text-cyan-400 flex items-center gap-2 mb-2">
|
<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-[var(--text-primary)] font-bold text-xs">${info.price.toFixed(2)}</span>
|
<span className="text-gray-200 font-bold text-xs">${info.price.toFixed(2)}</span>
|
||||||
<span className={`flex items-center gap-0.5 w-12 justify-end text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
|
<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-[var(--text-primary)] font-bold text-[11px]">${info.price.toFixed(2)}</span>
|
<span className="text-gray-200 font-bold text-[11px]">${info.price.toFixed(2)}</span>
|
||||||
<span className={`flex items-center gap-0.5 text-[9px] ${info.up ? 'text-cyan-400' : 'text-red-400'}`}>
|
<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,43 +3,9 @@
|
|||||||
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, useCallback } from 'react';
|
import React, { useEffect, useRef } 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 {
|
||||||
@@ -199,7 +165,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-[var(--text-muted)]">
|
<span className="text-[8px] text-gray-500">
|
||||||
{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>
|
||||||
@@ -211,43 +177,41 @@ 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-[var(--text-muted)]">COUNTRY</span><span className="text-[var(--text-primary)] font-bold">{d.country?.name}</span></div>
|
<div className="flex justify-between"><span className="text-gray-500">COUNTRY</span><span className="text-white 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-[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">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)]">LEADER</span><span className="text-emerald-400 font-bold">{d.country?.leader}</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)]">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">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)]">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">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)]">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">CAPITAL</span><span className="text-white font-bold">{d.country?.capital}</span></div>
|
||||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">LANGUAGES</span><span className="text-[var(--text-primary)] text-right max-w-[180px]">{d.country?.languages?.join(', ')}</span></div>
|
<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>
|
||||||
{d.country?.currencies?.length > 0 && (
|
{d.country?.currencies?.length > 0 && (
|
||||||
<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">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)]">REGION</span><span className="text-[var(--text-primary)]">{d.country?.subregion || d.country?.region}</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>
|
||||||
{d.country?.area_km2 > 0 && (
|
{d.country?.area_km2 > 0 && (
|
||||||
<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>
|
<div className="flex justify-between"><span className="text-gray-500">AREA</span><span className="text-white">{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-[var(--text-muted)]">LOCALITY</span><span className="text-[var(--text-primary)] font-bold">{d.local.name}</span></div>}
|
{d.local.name && <div className="flex justify-between"><span className="text-gray-500">LOCALITY</span><span className="text-white font-bold">{d.local.name}</span></div>}
|
||||||
{d.local.state && <div className="flex justify-between"><span className="text-[var(--text-muted)]">STATE/PROVINCE</span><span className="text-[var(--text-primary)] font-bold">{d.local.state}</span></div>}
|
{d.local.state && <div className="flex justify-between"><span className="text-gray-500">STATE/PROVINCE</span><span className="text-white font-bold">{d.local.state}</span></div>}
|
||||||
{d.local.description && <div className="flex justify-between"><span className="text-[var(--text-muted)]">TYPE</span><span className="text-[var(--text-secondary)]">{d.local.description}</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.summary && (
|
{d.local.summary && (
|
||||||
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 rounded text-[9px] text-[var(--text-secondary)] leading-relaxed">
|
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 rounded text-[9px] text-gray-300 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-[var(--text-secondary)] text-[10px]">{d.error}</div>
|
<div className="p-4 text-gray-400 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>
|
||||||
)}
|
)}
|
||||||
@@ -265,34 +229,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-[var(--border-primary)]/30'
|
'darkblue': 'border-blue-500/30', 'white': 'border-gray-500/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-[var(--bg-panel)]'
|
'darkblue': 'bg-blue-950/40', 'white': 'bg-gray-900/40'
|
||||||
};
|
};
|
||||||
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-[var(--border-primary)]/30';
|
const borderColor = alertBorderMap[ac] || 'border-gray-500/30';
|
||||||
const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]';
|
const bgColor = alertBgMap[ac] || 'bg-gray-900/40';
|
||||||
|
|
||||||
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-[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`}
|
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`}
|
||||||
>
|
>
|
||||||
<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-[var(--text-muted)] font-mono">TRK: {callsign}</span>
|
<span className="text-[10px] text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
|
<span className="text-gray-500 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, '_'))}`}
|
||||||
@@ -309,7 +273,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-[var(--border-primary)] pb-2">
|
<div className="border-b border-gray-800 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}
|
||||||
@@ -320,12 +284,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
)}
|
)}
|
||||||
{/* Aircraft model Wikipedia photo */}
|
{/* Aircraft model Wikipedia photo */}
|
||||||
{aircraftImgUrl && (
|
{aircraftImgUrl && (
|
||||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
<div className="border-b border-gray-800 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-[var(--border-primary)]/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-gray-700/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
{aircraftWikiUrl && (
|
{aircraftWikiUrl && (
|
||||||
@@ -336,65 +300,65 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">CATEGORY</span>
|
<span className="text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT</span>
|
<span className="text-gray-500 text-[10px]">AIRCRAFT</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
|
<span className="text-white text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAG</span>
|
<span className="text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">SECONDARY</span>
|
<span className="text-gray-500 text-[10px]">SECONDARY</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_tag2}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">DETAIL</span>
|
<span className="text-gray-500 text-[10px]">DETAIL</span>
|
||||||
<span className="text-[var(--text-secondary)] text-xs">{flight.alert_tag3}</span>
|
<span className="text-gray-400 text-xs">{flight.alert_tag3}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
|
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
|
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
|
<span className="text-gray-500 text-[10px]">HEADING</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">SQUAWK</span>
|
<span className="text-gray-500 text-[10px]">SQUAWK</span>
|
||||||
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{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-white'}`}>{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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">REFERENCE</span>
|
<span className="text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
|
<span className="text-gray-500 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>
|
||||||
@@ -453,34 +417,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-[var(--text-muted)] font-mono">TRK: {callsign}</span>
|
<span className="text-[10px] text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
|
<span className="text-gray-500 text-[10px]">OPERATOR</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{airline}</span>
|
<span className="text-white text-xs font-bold">{airline}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
<span className="text-gray-500 text-[10px]">REGISTRATION</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
|
<span className="text-white text-xs font-bold">{flight.registration || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT MODEL</span>
|
<span className="text-gray-500 text-[10px]">AIRCRAFT MODEL</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-3">
|
<div className="border-b border-gray-800 pb-3">
|
||||||
{aircraftImgLoading && (
|
{aircraftImgLoading && (
|
||||||
<div className="w-full h-24 rounded bg-[var(--bg-tertiary)]/60 animate-pulse" />
|
<div className="w-full h-24 rounded bg-gray-800/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-[var(--border-primary)]/50 hover:border-cyan-500/50 transition-colors"
|
className="w-full h-auto max-h-32 object-cover rounded border border-gray-700/50 hover:border-cyan-500/50 transition-colors"
|
||||||
style={{ imageRendering: 'auto' }}
|
style={{ imageRendering: 'auto' }}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
@@ -493,31 +457,31 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">ALTITUDE</span>
|
<span className="text-gray-500 text-[10px]">ALTITUDE</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{(Math.round((flight.alt || 0) / 0.3048)).toLocaleString()} ft</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">GROUND SPEED</span>
|
<span className="text-gray-500 text-[10px]">GROUND SPEED</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.speed_knots ? `${flight.speed_knots} kts` : 'N/A'}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">HEADING</span>
|
<span className="text-gray-500 text-[10px]">HEADING</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{Math.round(flight.heading || 0)}°</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">SQUAWK</span>
|
<span className="text-gray-500 text-[10px]">SQUAWK</span>
|
||||||
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{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-white'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">ROUTE</span>
|
<span className="text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
|
<span className="text-gray-500 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>
|
||||||
@@ -550,7 +514,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-[var(--text-secondary)]';
|
const headerColor = headerColorMap[ship.type] || 'text-gray-400';
|
||||||
|
|
||||||
const headerTitleMap: Record<string, string> = {
|
const headerTitleMap: Record<string, string> = {
|
||||||
'tanker': 'AIS TANKER INTERCEPT',
|
'tanker': 'AIS TANKER INTERCEPT',
|
||||||
@@ -573,49 +537,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-[var(--text-muted)] font-mono">MMSI: {ship.mmsi || 'N/A'}</span>
|
<span className="text-[10px] text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">VESSEL NAME</span>
|
<span className="text-gray-500 text-[10px]">VESSEL NAME</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
|
<span className="text-white text-xs font-bold text-right ml-4">{ship.name || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">VESSEL TYPE</span>
|
<span className="text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">FLAG STATE</span>
|
<span className="text-gray-500 text-[10px]">FLAG STATE</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
|
<span className="text-white text-xs font-bold">{ship.country || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
{ship.callsign && (
|
{ship.callsign && (
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">CALLSIGN</span>
|
<span className="text-gray-500 text-[10px]">CALLSIGN</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.callsign}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">IMO NUMBER</span>
|
<span className="text-gray-500 text-[10px]">IMO NUMBER</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.imo}</span>
|
<span className="text-white text-xs font-bold">{ship.imo}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">DESTINATION</span>
|
<span className="text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">SPEED (SOG)</span>
|
<span className="text-gray-500 text-[10px]">SPEED (SOG)</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${ship.sog || 0} kts`}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">COURSE (COG)</span>
|
<span className="text-gray-500 text-[10px]">COURSE (COG)</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{ship.type === 'carrier' ? 'UNKNOWN' : `${Math.round(ship.cog || 0)}°`}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">VESSEL RECORD</span>
|
<span className="text-gray-500 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>
|
||||||
@@ -623,7 +587,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-[var(--border-primary)] pt-2">
|
<div className="border-t border-gray-800 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}
|
||||||
@@ -653,22 +617,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-[var(--text-muted)] font-mono">ID: {selectedEntity.id}</span>
|
<span className="text-[10px] text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">LOCATION</span>
|
<span className="text-gray-500 text-[10px]">LOCATION</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{props.name || 'UNKNOWN REGION'}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">ARTICLE COUNT</span>
|
<span className="text-gray-500 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-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
|
<span className="text-gray-500 text-[10px]">LATEST REPORTS:</span>
|
||||||
<div
|
<div
|
||||||
className="text-[var(--text-primary)] text-xs whitespace-normal [&_a]:text-orange-400 [&_a]:underline hover:[&_a]:text-orange-300 [&_br]:mb-2"
|
className="text-white 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>
|
||||||
@@ -692,25 +656,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-[var(--text-muted)] font-mono">ID: {item.id}</span>
|
<span className="text-[10px] text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">REGION</span>
|
<span className="text-gray-500 text-[10px]">REGION</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
|
<span className="text-white text-xs font-bold text-right ml-4">{item.region || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
|
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">DESCRIPTION</span>
|
<span className="text-gray-500 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-[var(--border-primary)] pb-2 mt-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2 mt-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">REPORTED TIME</span>
|
<span className="text-gray-500 text-[10px]">REPORTED TIME</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{item.timestamp || 'UNKNOWN'}</span>
|
<span className="text-white 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-[var(--text-muted)] text-[10px]">SOURCE</span>
|
<span className="text-gray-500 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>
|
||||||
@@ -736,16 +700,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-[var(--text-muted)] font-mono">LVL: {item.risk_score}/10</span>
|
<span className="text-[10px] text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">SOURCE</span>
|
<span className="text-gray-500 text-[10px]">SOURCE</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
|
<span className="text-white text-xs font-bold text-right ml-4">{item.source || 'UNKNOWN'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 border-b border-[var(--border-primary)] pb-2">
|
<div className="flex flex-col gap-2 border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">HEADLINE</span>
|
<span className="text-gray-500 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 && (
|
||||||
@@ -757,7 +721,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-[var(--text-muted)] text-[10px]">REFERENCE</span>
|
<span className="text-gray-500 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>
|
||||||
@@ -783,20 +747,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-[var(--text-muted)] font-mono">IATA: {apt.iata}</span>
|
<span className="text-[10px] text-gray-500 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">FACILITY NAME</span>
|
<span className="text-gray-500 text-[10px]">FACILITY NAME</span>
|
||||||
<span className="text-[var(--text-primary)] text-[10px] font-bold text-right ml-4 break-words">{apt.name}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">COORDINATES</span>
|
<span className="text-gray-500 text-[10px]">COORDINATES</span>
|
||||||
<span className="text-[var(--text-primary)] text-xs font-bold">{apt.lat.toFixed(4)}, {apt.lng.toFixed(4)}</span>
|
<span className="text-white 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-[var(--border-primary)] pb-2">
|
<div className="flex justify-between items-center border-b border-gray-800 pb-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">STATUS</span>
|
<span className="text-gray-500 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>
|
||||||
@@ -819,7 +783,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-[var(--text-muted)] font-mono">ID: {selectedEntity.id}</span>
|
<span className="text-[10px] text-gray-500 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">
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -843,8 +807,11 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (mt === 'hls') return (
|
if (mt === 'hls') return (
|
||||||
<HlsVideo
|
<video
|
||||||
url={url}
|
src={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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -903,7 +870,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-[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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
<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"
|
||||||
@@ -913,7 +880,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-[var(--text-primary)] transition-colors">
|
<button className="text-cyan-500 hover:text-white transition-colors">
|
||||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -971,14 +938,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-[var(--text-secondary)] uppercase tracking-widest">
|
<div className="flex items-center justify-between text-[8px] text-gray-400 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-[var(--text-primary)] transition-colors leading-tight`}>
|
<a href={item.link} target="_blank" rel="noreferrer" className={`text-[11px] ${titleClass} hover:text-white transition-colors leading-tight`}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -996,12 +963,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-[var(--text-primary)] 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-white 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-[var(--text-muted)] font-mono tracking-tighter">
|
<span className="text-[8px] text-gray-500 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>
|
||||||
)}
|
)}
|
||||||
@@ -1018,7 +985,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-[var(--text-muted)] uppercase font-bold">
|
<div className="flex items-center justify-between text-[7.5px] text-gray-500 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' :
|
||||||
@@ -1027,7 +994,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-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors leading-tight">
|
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-gray-400 hover:text-white transition-colors leading-tight">
|
||||||
{subItem.title}
|
{subItem.title}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
"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,11 +1,10 @@
|
|||||||
"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, 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 }) {
|
export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesdropping, eavesdropLocation, cameraCenter }: { data: any, isEavesdropping?: boolean, setIsEavesdropping?: (val: boolean) => void, eavesdropLocation?: { lat: number, lng: number } | null, cameraCenter?: { lat: number, lng: number } | null }) {
|
||||||
const [isMinimized, setIsMinimized] = useState(true);
|
const [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);
|
||||||
@@ -19,7 +18,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFeeds = async () => {
|
const fetchFeeds = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/radio/top`);
|
const res = await fetch("http://localhost:8000/api/radio/top");
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setFeeds(json);
|
setFeeds(json);
|
||||||
@@ -48,12 +47,12 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
|||||||
category: 'SIGINT'
|
category: 'SIGINT'
|
||||||
}, ...prev]);
|
}, ...prev]);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/api/radio/nearest?lat=${eavesdropLocation.lat}&lng=${eavesdropLocation.lng}`);
|
const res = await fetch(`http://localhost:8000/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(`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`);
|
const callRes = await fetch(`http://localhost:8000/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) {
|
||||||
@@ -190,14 +189,14 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
|||||||
|
|
||||||
if (scanLoc) {
|
if (scanLoc) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/radio/nearest-list?lat=${scanLoc.lat}&lng=${scanLoc.lng}&limit=3`);
|
const res = await fetch(`http://localhost:8000/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(`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`);
|
const callRes = await fetch(`http://localhost:8000/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) {
|
||||||
@@ -249,7 +248,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-[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"
|
className="w-full flex flex-col bg-black/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] relative overflow-hidden max-h-full"
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
||||||
@@ -274,13 +273,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-[var(--bg-primary)]/60">
|
<div className="p-4 border-b border-cyan-900/40 bg-black/60">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex 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-[var(--text-muted)] font-mono">
|
<span className="text-[9px] text-gray-500 font-mono">
|
||||||
{activeFeed ? `LOCATION: ${activeFeed.location.toUpperCase()}` : "AWAITING TUNING..."}
|
{activeFeed ? `LOCATION: ${activeFeed.location.toUpperCase()}` : "AWAITING TUNING..."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,36 +346,6 @@ 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 ? (
|
||||||
@@ -389,10 +358,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-[var(--text-secondary)]'}`}>
|
<span className={`text-[11px] font-mono truncate ${activeFeed?.id === feed.id ? 'text-cyan-300' : 'text-gray-300'}`}>
|
||||||
{feed.name}
|
{feed.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-[var(--text-muted)] font-mono truncate">
|
<span className="text-[9px] text-gray-500 font-mono truncate">
|
||||||
{feed.location} | {feed.category}
|
{feed.location} | {feed.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -401,7 +370,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-[var(--text-muted)] font-mono mt-0.5">LSTN</span>
|
<span className="text-[8px] text-gray-600 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-[var(--border-primary)] hover:border-cyan-500/50 text-[var(--text-muted)] 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-gray-700 hover:border-cyan-500/50 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 uppercase"
|
||||||
title={`Switch to ${unit === "mi" ? "Metric (km)" : "Imperial (mi)"}`}
|
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-[var(--border-primary)] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-950/20"
|
: "border-gray-700 text-gray-500 hover:text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-950/20"
|
||||||
}`}
|
}`}
|
||||||
title={measureMode ? "Exit measurement mode" : "Measure distance (click up to 3 points)"}
|
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-[var(--border-primary)] text-[var(--text-muted)] 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-gray-700 text-gray-500 hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/20 transition-all"
|
||||||
title="Clear all waypoints"
|
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-[var(--border-primary)] text-[var(--text-secondary)]"
|
: "border-gray-700 text-gray-400"
|
||||||
}`}>
|
}`}>
|
||||||
{d}
|
{d}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"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, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp, Rss, Plus, Trash2, RotateCcw } from "lucide-react";
|
import { Settings, Eye, EyeOff, Copy, Check, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
interface ApiEntry {
|
interface ApiEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,25 +14,9 @@ interface ApiEntry {
|
|||||||
has_key: boolean;
|
has_key: boolean;
|
||||||
env_key: string | null;
|
env_key: string | null;
|
||||||
value_obfuscated: string | null;
|
value_obfuscated: string | null;
|
||||||
is_set: boolean;
|
value_plain: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeedEntry {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
weight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WEIGHT_LABELS: Record<number, string> = { 1: "LOW", 2: "MED", 3: "STD", 4: "HIGH", 5: "CRIT" };
|
|
||||||
const WEIGHT_COLORS: Record<number, string> = {
|
|
||||||
1: "text-gray-400 border-gray-600",
|
|
||||||
2: "text-blue-400 border-blue-600",
|
|
||||||
3: "text-cyan-400 border-cyan-600",
|
|
||||||
4: "text-orange-400 border-orange-600",
|
|
||||||
5: "text-red-400 border-red-600",
|
|
||||||
};
|
|
||||||
const MAX_FEEDS = 20;
|
|
||||||
|
|
||||||
// Category colors for the tactical UI
|
// Category colors for the tactical UI
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
||||||
@@ -47,139 +30,91 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
|
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Tab = "api-keys" | "news-feeds";
|
|
||||||
|
|
||||||
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 [activeTab, setActiveTab] = useState<Tab>("api-keys");
|
|
||||||
|
|
||||||
// --- API Keys state ---
|
|
||||||
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);
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
||||||
|
|
||||||
// --- News Feeds state ---
|
|
||||||
const [feeds, setFeeds] = useState<FeedEntry[]>([]);
|
|
||||||
const [feedsDirty, setFeedsDirty] = useState(false);
|
|
||||||
const [feedSaving, setFeedSaving] = useState(false);
|
|
||||||
const [feedMsg, setFeedMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
|
||||||
|
|
||||||
const fetchKeys = useCallback(async () => {
|
const fetchKeys = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
|
const res = await fetch("http://localhost:8000/api/settings/api-keys");
|
||||||
if (res.ok) setApis(await res.json());
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setApis(data);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch API keys", e);
|
console.error("Failed to fetch API keys", e);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchFeeds = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/api/settings/news-feeds`);
|
|
||||||
if (res.ok) {
|
|
||||||
setFeeds(await res.json());
|
|
||||||
setFeedsDirty(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch news feeds", e);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) fetchKeys();
|
||||||
fetchKeys();
|
}, [isOpen, fetchKeys]);
|
||||||
fetchFeeds();
|
|
||||||
}
|
|
||||||
}, [isOpen, fetchKeys, fetchFeeds]);
|
|
||||||
|
|
||||||
// API Keys handlers
|
const toggleReveal = (id: string) => {
|
||||||
const startEditing = (api: ApiEntry) => { setEditingId(api.id); setEditValue(""); };
|
setRevealedKeys(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (id: string, value: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopiedId(id);
|
||||||
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API may fail in some contexts
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = (api: ApiEntry) => {
|
||||||
|
setEditingId(api.id);
|
||||||
|
setEditValue(api.value_plain || "");
|
||||||
|
};
|
||||||
|
|
||||||
const saveKey = async (api: ApiEntry) => {
|
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(`${API_BASE}/api/settings/api-keys`, {
|
const res = await fetch("http://localhost:8000/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 }),
|
||||||
});
|
});
|
||||||
if (res.ok) { setEditingId(null); fetchKeys(); }
|
if (res.ok) {
|
||||||
|
setEditingId(null);
|
||||||
|
fetchKeys(); // Refresh to get new obfuscated value
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save API key", e);
|
console.error("Failed to save API key", e);
|
||||||
} finally { setSaving(false); }
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCategory = (cat: string) => {
|
const toggleCategory = (cat: string) => {
|
||||||
setExpandedCategories(prev => {
|
setExpandedCategories(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(cat)) next.delete(cat); else next.add(cat);
|
if (next.has(cat)) next.delete(cat);
|
||||||
|
else next.add(cat);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Group APIs by category
|
||||||
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
||||||
if (!acc[api.category]) acc[api.category] = [];
|
if (!acc[api.category]) acc[api.category] = [];
|
||||||
acc[api.category].push(api);
|
acc[api.category].push(api);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// News Feeds handlers
|
|
||||||
const updateFeed = (idx: number, field: keyof FeedEntry, value: string | number) => {
|
|
||||||
setFeeds(prev => prev.map((f, i) => i === idx ? { ...f, [field]: value } : f));
|
|
||||||
setFeedsDirty(true);
|
|
||||||
setFeedMsg(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFeed = (idx: number) => {
|
|
||||||
setFeeds(prev => prev.filter((_, i) => i !== idx));
|
|
||||||
setFeedsDirty(true);
|
|
||||||
setFeedMsg(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addFeed = () => {
|
|
||||||
if (feeds.length >= MAX_FEEDS) return;
|
|
||||||
setFeeds(prev => [...prev, { name: "", url: "", weight: 3 }]);
|
|
||||||
setFeedsDirty(true);
|
|
||||||
setFeedMsg(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveFeeds = async () => {
|
|
||||||
setFeedSaving(true);
|
|
||||||
setFeedMsg(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/api/settings/news-feeds`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(feeds),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setFeedsDirty(false);
|
|
||||||
setFeedMsg({ type: "ok", text: "Feeds saved. Changes take effect on next news refresh (~30min) or manual /api/refresh." });
|
|
||||||
} else {
|
|
||||||
const d = await res.json().catch(() => ({}));
|
|
||||||
setFeedMsg({ type: "err", text: d.message || "Save failed" });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setFeedMsg({ type: "err", text: "Network error" });
|
|
||||||
} finally { setFeedSaving(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetFeeds = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/api/settings/news-feeds/reset`, { method: "POST" });
|
|
||||||
if (res.ok) {
|
|
||||||
const d = await res.json();
|
|
||||||
setFeeds(d.feeds || []);
|
|
||||||
setFeedsDirty(false);
|
|
||||||
setFeedMsg({ type: "ok", text: "Reset to defaults" });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setFeedMsg({ type: "err", text: "Reset failed" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -199,258 +134,192 @@ 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-[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)]"
|
className="fixed left-0 top-0 bottom-0 w-[480px] bg-gray-950/95 backdrop-blur-xl border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.8)]"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]/80">
|
<div className="flex items-center justify-between p-6 border-b border-gray-800/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-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
|
<h2 className="text-sm font-bold tracking-[0.2em] text-white font-mono">SYSTEM CONFIG</h2>
|
||||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">SETTINGS & DATA SOURCES</span>
|
<span className="text-[9px] text-gray-500 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-[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"
|
className="w-8 h-8 rounded-lg border border-gray-700 hover:border-red-500/50 flex items-center justify-center text-gray-500 hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Bar */}
|
{/* Info Banner */}
|
||||||
<div className="flex border-b border-[var(--border-primary)]/60">
|
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
||||||
<button
|
<div className="flex items-start gap-2">
|
||||||
onClick={() => setActiveTab("api-keys")}
|
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||||
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "api-keys" ? "text-cyan-400 border-b-2 border-cyan-500 bg-cyan-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
|
<p className="text-[10px] text-gray-400 font-mono leading-relaxed">
|
||||||
>
|
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
||||||
<Key size={10} />
|
</p>
|
||||||
API KEYS
|
</div>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("news-feeds")}
|
|
||||||
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "news-feeds" ? "text-orange-400 border-b-2 border-orange-500 bg-orange-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
|
|
||||||
>
|
|
||||||
<Rss size={10} />
|
|
||||||
NEWS FEEDS
|
|
||||||
{feedsDirty && <span className="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ==================== API KEYS TAB ==================== */}
|
{/* API List */}
|
||||||
{activeTab === "api-keys" && (
|
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
||||||
<>
|
{Object.entries(grouped).map(([category, categoryApis]) => {
|
||||||
{/* Info Banner */}
|
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
||||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
const isExpanded = expandedCategories.has(category);
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
||||||
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API List */}
|
return (
|
||||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
<div key={category} className="rounded-lg border border-gray-800/60 overflow-hidden">
|
||||||
{Object.entries(grouped).map(([category, categoryApis]) => {
|
{/* Category Header */}
|
||||||
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
<button
|
||||||
const isExpanded = expandedCategories.has(category);
|
onClick={() => toggleCategory(category)}
|
||||||
return (
|
className="w-full flex items-center justify-between px-4 py-2.5 bg-gray-900/50 hover:bg-gray-900/80 transition-colors"
|
||||||
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
|
>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => toggleCategory(category)}
|
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
||||||
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"
|
{category.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-500 font-mono">
|
||||||
|
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? <ChevronUp size={12} className="text-gray-500" /> : <ChevronDown size={12} className="text-gray-500" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* APIs in Category */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
{categoryApis.map((api) => (
|
||||||
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
<div key={api.id} className="border-t border-gray-800/40 px-4 py-3 hover:bg-gray-900/30 transition-colors">
|
||||||
{category.toUpperCase()}
|
{/* API Name + Status */}
|
||||||
</span>
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">
|
<div className="flex items-center gap-2">
|
||||||
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
{api.required && <Key size={10} className="text-yellow-500" />}
|
||||||
</span>
|
<span className="text-xs font-mono text-white font-medium">{api.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
|
<div className="flex items-center gap-1.5">
|
||||||
</button>
|
{api.has_key ? (
|
||||||
<AnimatePresence>
|
<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">
|
||||||
{isExpanded && (
|
KEY SET
|
||||||
<motion.div
|
</span>
|
||||||
initial={{ height: 0, opacity: 0 }}
|
) : (
|
||||||
animate={{ height: "auto", opacity: 1 }}
|
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-gray-700 text-gray-500">
|
||||||
exit={{ height: 0, opacity: 0 }}
|
PUBLIC
|
||||||
transition={{ duration: 0.2 }}
|
</span>
|
||||||
>
|
)}
|
||||||
{categoryApis.map((api) => (
|
{api.url && (
|
||||||
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
|
<a
|
||||||
<div className="flex items-center justify-between mb-1">
|
href={api.url}
|
||||||
<div className="flex items-center gap-2">
|
target="_blank"
|
||||||
{api.required && <Key size={10} className="text-yellow-500" />}
|
rel="noopener noreferrer"
|
||||||
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
|
className="text-gray-600 hover:text-cyan-400 transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ExternalLink size={10} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-[10px] text-gray-500 font-mono leading-relaxed mb-2">
|
||||||
|
{api.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Key Field (only for APIs with keys) */}
|
||||||
|
{api.has_key && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{editingId === api.id ? (
|
||||||
|
/* Edit Mode */
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
|
||||||
|
placeholder="Enter API key..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveKey(api)}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Save size={10} />
|
||||||
|
{saving ? "..." : "SAVE"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
className="px-2 py-1.5 rounded border border-gray-700 text-gray-500 hover:text-white hover:border-gray-600 transition-colors text-[10px] font-mono"
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Display Mode */
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{api.has_key ? (
|
<div
|
||||||
api.is_set ? (
|
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"
|
||||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">KEY SET</span>
|
onClick={() => startEditing(api)}
|
||||||
) : (
|
>
|
||||||
<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={revealedKeys.has(api.id) ? "text-cyan-300" : "text-gray-500 tracking-wider"}>
|
||||||
)
|
{revealedKeys.has(api.id) ? api.value_plain : api.value_obfuscated}
|
||||||
) : (
|
</span>
|
||||||
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">PUBLIC</span>
|
</div>
|
||||||
)}
|
|
||||||
{api.url && (
|
{/* Eye Toggle */}
|
||||||
<a href={api.url} target="_blank" rel="noopener noreferrer" className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors" onClick={(e) => e.stopPropagation()}>
|
<button
|
||||||
<ExternalLink size={10} />
|
onClick={() => toggleReveal(api.id)}
|
||||||
</a>
|
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"
|
||||||
</div>
|
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
||||||
</div>
|
}`}
|
||||||
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">{api.description}</p>
|
title={revealedKeys.has(api.id) ? "Hide key" : "Reveal key"}
|
||||||
{api.has_key && (
|
>
|
||||||
<div className="mt-2">
|
{revealedKeys.has(api.id) ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||||
{editingId === api.id ? (
|
</button>
|
||||||
<div className="flex gap-2">
|
|
||||||
<input type="text" value={editValue} onChange={(e) => setEditValue(e.target.value)} className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors" placeholder="Enter API key..." autoFocus />
|
{/* Copy */}
|
||||||
<button onClick={() => saveKey(api)} disabled={saving} className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1">
|
<button
|
||||||
<Save size={10} />{saving ? "..." : "SAVE"}
|
onClick={() => copyToClipboard(api.id, api.value_plain || "")}
|
||||||
</button>
|
className={`w-7 h-7 rounded flex items-center justify-center border transition-all ${copiedId === api.id
|
||||||
<button onClick={() => setEditingId(null)} className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono">ESC</button>
|
? "border-green-500/40 text-green-400 bg-green-950/30"
|
||||||
</div>
|
: "border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600"
|
||||||
) : (
|
}`}
|
||||||
<div className="flex items-center gap-1.5">
|
title="Copy to clipboard"
|
||||||
<div className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none" onClick={() => startEditing(api)}>
|
>
|
||||||
<span className="text-[var(--text-muted)] tracking-wider">{api.is_set ? api.value_obfuscated : "Click to set key..."}</span>
|
{copiedId === api.id ? <Check size={12} /> : <Copy size={12} />}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
|
||||||
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
|
|
||||||
<span>{apis.length} REGISTERED APIs</span>
|
|
||||||
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ==================== NEWS FEEDS TAB ==================== */}
|
|
||||||
{activeTab === "news-feeds" && (
|
|
||||||
<>
|
|
||||||
{/* Info Banner */}
|
|
||||||
<div className="mx-4 mt-4 p-3 rounded-lg border border-orange-900/30 bg-orange-950/10">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Rss size={12} className="text-orange-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
||||||
Configure RSS/Atom feeds for the Threat Intel news panel. Each feed is scored by keyword heuristics and weighted by the priority you set. Up to <span className="text-orange-400">{MAX_FEEDS}</span> sources.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feed List */}
|
|
||||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
|
|
||||||
{feeds.map((feed, idx) => (
|
|
||||||
<div key={idx} className="rounded-lg border border-[var(--border-primary)]/60 p-3 hover:border-[var(--border-secondary)]/60 transition-colors group">
|
|
||||||
{/* Row 1: Name + Weight + Delete */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={feed.name}
|
|
||||||
onChange={(e) => updateFeed(idx, "name", e.target.value)}
|
|
||||||
className="flex-1 bg-transparent border-b border-[var(--border-primary)] text-xs font-mono text-[var(--text-primary)] outline-none focus:border-cyan-500/70 transition-colors px-1 py-0.5"
|
|
||||||
placeholder="Source name..."
|
|
||||||
/>
|
|
||||||
{/* Weight selector */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{[1, 2, 3, 4, 5].map(w => (
|
|
||||||
<button
|
|
||||||
key={w}
|
|
||||||
onClick={() => updateFeed(idx, "weight", w)}
|
|
||||||
className={`w-5 h-5 rounded text-[8px] font-mono font-bold border transition-all ${feed.weight === w ? WEIGHT_COLORS[w] + " bg-black/40" : "border-[var(--border-primary)]/40 text-[var(--text-muted)]/50 hover:border-[var(--border-secondary)]"}`}
|
|
||||||
title={WEIGHT_LABELS[w]}
|
|
||||||
>
|
|
||||||
{w}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
<span className={`text-[8px] font-mono ml-1 w-7 ${WEIGHT_COLORS[feed.weight]?.split(" ")[0] || "text-gray-400"}`}>
|
</motion.div>
|
||||||
{WEIGHT_LABELS[feed.weight] || "STD"}
|
)}
|
||||||
</span>
|
</AnimatePresence>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => removeFeed(idx)}
|
|
||||||
className="w-6 h-6 rounded flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 hover:bg-red-950/20 transition-all opacity-0 group-hover:opacity-100"
|
|
||||||
title="Remove feed"
|
|
||||||
>
|
|
||||||
<Trash2 size={11} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/* Row 2: URL */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={feed.url}
|
|
||||||
onChange={(e) => updateFeed(idx, "url", e.target.value)}
|
|
||||||
className="w-full bg-black/30 border border-[var(--border-primary)]/40 rounded px-2 py-1 text-[10px] font-mono text-[var(--text-muted)] outline-none focus:border-cyan-500/50 focus:text-cyan-300 transition-colors"
|
|
||||||
placeholder="https://example.com/rss.xml"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Add Feed Button */}
|
|
||||||
<button
|
|
||||||
onClick={addFeed}
|
|
||||||
disabled={feeds.length >= MAX_FEEDS}
|
|
||||||
className="w-full py-2.5 rounded-lg border border-dashed border-[var(--border-primary)]/60 text-[var(--text-muted)] hover:border-orange-500/50 hover:text-orange-400 hover:bg-orange-950/10 transition-all text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Plus size={10} />
|
|
||||||
ADD FEED ({feeds.length}/{MAX_FEEDS})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status message */}
|
|
||||||
{feedMsg && (
|
|
||||||
<div className={`mx-4 mb-2 px-3 py-2 rounded text-[10px] font-mono ${feedMsg.type === "ok" ? "text-green-400 bg-green-950/20 border border-green-900/30" : "text-red-400 bg-red-950/20 border border-red-900/30"}`}>
|
|
||||||
{feedMsg.text}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
<div className="p-4 border-t border-gray-800/80">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between text-[9px] text-gray-600 font-mono">
|
||||||
<button
|
<span>{apis.length} REGISTERED APIs</span>
|
||||||
onClick={saveFeeds}
|
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
||||||
disabled={!feedsDirty || feedSaving}
|
</div>
|
||||||
className="flex-1 px-4 py-2 rounded bg-orange-500/20 border border-orange-500/40 text-orange-400 hover:bg-orange-500/30 transition-colors text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
</div>
|
||||||
>
|
|
||||||
<Save size={10} />
|
|
||||||
{feedSaving ? "SAVING..." : "SAVE FEEDS"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={resetFeeds}
|
|
||||||
className="px-3 py-2 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-all text-[10px] font-mono flex items-center gap-1.5"
|
|
||||||
title="Reset to defaults"
|
|
||||||
>
|
|
||||||
<RotateCcw size={10} />
|
|
||||||
RESET
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono mt-2">
|
|
||||||
<span>{feeds.length}/{MAX_FEEDS} SOURCES</span>
|
|
||||||
<span>WEIGHT: 1=LOW 5=CRITICAL</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.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-[var(--bg-tertiary)]/60 animate-pulse`} />
|
<div className={`w-full h-20 rounded bg-gray-800/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-[var(--border-primary)]/50 ${accent} transition-colors`}
|
className={`w-full h-auto ${maxH} object-cover rounded border border-gray-700/50 ${accent} transition-colors`}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,74 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState } 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, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server } from "lucide-react";
|
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, BookOpen, Radio } from "lucide-react";
|
||||||
import { useTheme } from "@/lib/ThemeContext";
|
|
||||||
|
|
||||||
function relativeTime(iso: string | undefined): string {
|
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void }) {
|
||||||
if (!iso) return "";
|
|
||||||
const diff = Date.now() - new Date(iso + "Z").getTime();
|
|
||||||
if (diff < 0) return "now";
|
|
||||||
const sec = Math.floor(diff / 1000);
|
|
||||||
if (sec < 60) return `${sec}s ago`;
|
|
||||||
const min = Math.floor(sec / 60);
|
|
||||||
if (min < 60) return `${min}m ago`;
|
|
||||||
const hr = Math.floor(min / 60);
|
|
||||||
if (hr < 24) return `${hr}h ago`;
|
|
||||||
return `${Math.floor(hr / 24)}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map layer IDs to freshness keys from the backend source_timestamps dict
|
|
||||||
const FRESHNESS_MAP: Record<string, string> = {
|
|
||||||
flights: "commercial_flights",
|
|
||||||
private: "private_flights",
|
|
||||||
jets: "private_jets",
|
|
||||||
military: "military_flights",
|
|
||||||
tracked: "military_flights",
|
|
||||||
earthquakes: "earthquakes",
|
|
||||||
satellites: "satellites",
|
|
||||||
ships_important: "ships",
|
|
||||||
ships_civilian: "ships",
|
|
||||||
ships_passenger: "ships",
|
|
||||||
ukraine_frontline: "frontlines",
|
|
||||||
global_incidents: "gdelt",
|
|
||||||
cctv: "cctv",
|
|
||||||
gps_jamming: "commercial_flights",
|
|
||||||
kiwisdr: "kiwisdr",
|
|
||||||
firms: "firms_fires",
|
|
||||||
internet_outages: "internet_outages",
|
|
||||||
datacenters: "datacenters",
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -90,12 +27,6 @@ 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: "firms", name: "Fire Hotspots (24h)", source: "NASA FIRMS VIIRS", count: data?.firms_fires?.length || 0, icon: Flame },
|
|
||||||
{ id: "internet_outages", name: "Internet Outages", source: "IODA / Georgia Tech", count: data?.internet_outages?.length || 0, icon: Wifi },
|
|
||||||
{ id: "datacenters", name: "Data Centers", source: "DC Map (GitHub)", count: data?.datacenters?.length || 0, icon: Server },
|
|
||||||
{ 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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -110,21 +41,14 @@ 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-[var(--text-secondary)] font-mono tracking-widest mb-1">TOP SECRET // SI-TK // NOFORN</div>
|
<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-muted)] font-mono tracking-widest mb-4">KH11-4094 OPS-4168</div>
|
<div className="text-[10px] text-gray-500 font-mono tracking-widest mb-4">KH11-4094 OPS-4168</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1>
|
<h1 className="text-2xl font-bold tracking-[0.2em] text-cyan-50">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-[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"
|
className="w-7 h-7 rounded-lg border border-gray-700 hover:border-cyan-500/50 flex items-center justify-center text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20 group"
|
||||||
title="System Settings"
|
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" />
|
||||||
@@ -133,7 +57,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-[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)]"
|
className="h-7 px-2 rounded-lg border border-gray-700 hover:border-cyan-500/50 flex items-center justify-center gap-1 text-gray-500 hover:text-cyan-400 transition-all hover:bg-cyan-950/20"
|
||||||
title="Map Legend / Icon Key"
|
title="Map Legend / Icon Key"
|
||||||
>
|
>
|
||||||
<BookOpen size={12} />
|
<BookOpen size={12} />
|
||||||
@@ -144,15 +68,15 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data Layers Box */}
|
{/* Data Layers Box */}
|
||||||
<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">
|
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.5)] flex flex-col relative overflow-hidden max-h-full">
|
||||||
|
|
||||||
{/* Header / Toggle */}
|
{/* Header / Toggle */}
|
||||||
<div
|
<div
|
||||||
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"
|
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||||
onClick={() => setIsMinimized(!isMinimized)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DATA LAYERS</span>
|
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DATA LAYERS</span>
|
||||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
<button className="text-gray-500 hover:text-white transition-colors">
|
||||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,83 +95,31 @@ 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 key={idx} className="flex flex-col">
|
<div
|
||||||
<div
|
key={idx}
|
||||||
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 ? (() => {
|
|
||||||
const fKey = FRESHNESS_MAP[layer.id];
|
|
||||||
const freshness = fKey && data?.freshness?.[fKey];
|
|
||||||
const rt = freshness ? relativeTime(freshness) : '';
|
|
||||||
return rt ? <span className="text-cyan-500/70">{rt}</span> : 'LIVE';
|
|
||||||
})() : 'OFF'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col">
|
||||||
{active && layer.count > 0 && (
|
<span className={`text-sm font-medium ${active ? 'text-white' : 'text-gray-400'} tracking-wide`}>{layer.name}</span>
|
||||||
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
|
<span className="text-[9px] text-gray-600 font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</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>
|
||||||
{/* GIBS Imagery inline controls: time slider + play/pause + opacity */}
|
<div className="flex items-center gap-3">
|
||||||
{active && layer.id === 'gibs_imagery' && gibsDate && setGibsDate && setGibsOpacity && (
|
{active && layer.count > 0 && (
|
||||||
<div className="ml-7 mt-2 flex flex-col gap-2" onClick={e => e.stopPropagation()}>
|
<span className="text-[10px] text-gray-300 font-mono">{layer.count.toLocaleString()}</span>
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
<button
|
<div className={`text-[9px] font-mono tracking-wider px-2 py-0.5 rounded-full border ${active
|
||||||
onClick={() => setGibsPlaying(p => !p)}
|
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/30 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
|
||||||
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"
|
: 'border-gray-800 text-gray-600 bg-transparent'
|
||||||
>
|
}`}>
|
||||||
{gibsPlaying ? <Pause size={10} /> : <Play size={10} />}
|
{active ? 'ON' : 'OFF'}
|
||||||
</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-[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]'}`}
|
className={`w-full bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl z-10 flex flex-col font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 flex-shrink-0 ${isMinimized ? 'h-[50px]' : 'h-[320px]'}`}
|
||||||
>
|
>
|
||||||
{/* Record / Orbit Tracker Header */}
|
{/* Record / Orbit Tracker Header */}
|
||||||
<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="flex items-center gap-3 mb-6 border border-gray-800 bg-black/40 backdrop-blur-md px-4 py-2 rounded-sm relative shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto">
|
||||||
<div className="absolute -top-1 -left-1 w-2 h-2 border-t border-l border-[var(--text-muted)]/50"></div>
|
<div className="absolute -top-1 -left-1 w-2 h-2 border-t border-l border-gray-500/50"></div>
|
||||||
<div className="absolute -bottom-1 -right-1 w-2 h-2 border-b border-r border-[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="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-[var(--text-secondary)] tracking-wider">
|
<div className="text-[10px] font-mono text-gray-400 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-[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">
|
<div className="bg-black/40 backdrop-blur-md border border-gray-800 rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
|
||||||
|
|
||||||
{/* Header / Toggle */}
|
{/* Header / Toggle */}
|
||||||
<div
|
<div
|
||||||
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"
|
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-900/50 transition-colors border-b border-gray-800/50"
|
||||||
onClick={() => setIsMinimized(!isMinimized)}
|
onClick={() => setIsMinimized(!isMinimized)}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DISPLAY CONFIG</span>
|
<span className="text-[10px] text-gray-500 font-mono tracking-widest">DISPLAY CONFIG</span>
|
||||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
<button className="text-gray-500 hover:text-white 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-[var(--border-primary)]'}`}
|
className={`flex items-center justify-between group cursor-pointer border rounded px-4 py-3 transition-colors ${effects.bloom ? 'border-yellow-900/50 bg-yellow-950/10' : 'border-gray-800'}`}
|
||||||
onClick={() => setEffects({ ...effects, bloom: !effects.bloom })}
|
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-[var(--text-primary)]' : 'text-[var(--text-muted)]'}`}>BLOOM</span>
|
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-white' : 'text-gray-500'}`}>BLOOM</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-mono tracking-wider text-[var(--text-muted)]">{effects.bloom ? 'ON' : 'OFF'}</span>
|
<span className="text-[9px] font-mono tracking-wider text-gray-500">{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-[var(--border-primary)] flex-1 relative rounded-full">
|
<div className="h-0.5 bg-gray-800 flex-1 relative rounded-full">
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[49%] bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.5)]"></div>
|
<div className="absolute left-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-[var(--border-primary)] rounded px-4 py-3 text-[var(--text-muted)] cursor-default">
|
<div className="flex items-center gap-3 border border-gray-800 rounded px-4 py-3 text-gray-500 cursor-default">
|
||||||
<span className="w-3 h-3 border border-gray-500 rounded-full flex items-center justify-center"></span>
|
<span className="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-[var(--border-primary)] rounded px-4 py-2 mt-1 bg-[var(--bg-primary)]/50">
|
<div className="flex items-center justify-between border border-gray-800 rounded px-4 py-2 mt-1 bg-black/50">
|
||||||
<span className="text-[10px] text-[var(--text-muted)] font-mono">LAYOUT</span>
|
<span className="text-[10px] text-gray-500 font-mono">LAYOUT</span>
|
||||||
<span className="text-xs text-[var(--text-primary)] tracking-widest border-b border-dashed border-[var(--border-secondary)] pb-0.5 cursor-pointer flex items-center gap-2">
|
<span className="text-xs text-white tracking-widest border-b border-dashed border-gray-600 pb-0.5 cursor-pointer flex items-center gap-2">
|
||||||
Tactical
|
Tactical
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"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);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// 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();
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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.")
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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,92 +5,28 @@ 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 (warn if 3.13+)
|
|
||||||
for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYVER=%%v
|
|
||||||
echo [*] Found Python %PYVER%
|
|
||||||
for /f "tokens=1,2 delims=." %%a in ("%PYVER%") do (
|
|
||||||
if %%b GEQ 13 (
|
|
||||||
echo [!] WARNING: Python %PYVER% detected. Some packages may fail to build.
|
|
||||||
echo [!] Recommended: Python 3.10, 3.11, or 3.12.
|
|
||||||
echo.
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
:: 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
|
||||||
echo [*] Installing Python dependencies (this may take a minute)...
|
pip install -r requirements.txt >nul 2>&1
|
||||||
pip install -q -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.
|
|
||||||
echo [*] Installing backend Node.js dependencies...
|
|
||||||
call npm install --silent
|
|
||||||
echo [*] Backend Node.js dependencies OK.
|
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo [*] Setting up frontend...
|
echo Installing frontend dependencies if needed...
|
||||||
cd frontend
|
cd frontend
|
||||||
if not exist "node_modules\" (
|
if not exist "node_modules\" (
|
||||||
echo [*] Installing frontend dependencies...
|
echo Running npm install...
|
||||||
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 ===================================================
|
echo Starting both services...
|
||||||
echo Starting services...
|
echo (Press Ctrl+C to stop the dashboard)
|
||||||
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,85 +6,47 @@ 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 18+ (https://nodejs.org/)"
|
echo "[!] ERROR: npm is not installed. Please install Node.js (https://nodejs.org/)"
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[*] Found Node.js $(node --version)"
|
|
||||||
|
|
||||||
# Check for Python 3
|
|
||||||
PYTHON_CMD=""
|
|
||||||
if command -v python3 &> /dev/null; then
|
|
||||||
PYTHON_CMD="python3"
|
|
||||||
elif command -v python &> /dev/null; then
|
|
||||||
PYTHON_CMD="python"
|
|
||||||
else
|
|
||||||
echo "[!] ERROR: Python is not installed."
|
|
||||||
echo "[!] Install Python 3.10-3.12 from https://python.org"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PYVER=$($PYTHON_CMD --version 2>&1 | awk '{print $2}')
|
# Check for Python
|
||||||
echo "[*] Found Python $PYVER"
|
if ! command -v python3 &> /dev/null; then
|
||||||
PY_MINOR=$(echo "$PYVER" | cut -d. -f2)
|
echo "[!] ERROR: python3 is not installed. Please install Python 3.10+ (https://python.org/)"
|
||||||
if [ "$PY_MINOR" -ge 13 ] 2>/dev/null; then
|
exit 1
|
||||||
echo "[!] WARNING: Python $PYVER detected. Some packages may fail to build."
|
|
||||||
echo "[!] Recommended: Python 3.10, 3.11, or 3.12."
|
|
||||||
echo ""
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get the directory where this script lives
|
echo "[*] Setting up Backend Environment..."
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
cd backend
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[*] Setting up backend..."
|
|
||||||
cd "$SCRIPT_DIR/backend"
|
|
||||||
if [ ! -d "venv" ]; then
|
if [ ! -d "venv" ]; then
|
||||||
echo "[*] Creating Python virtual environment..."
|
echo "[*] Creating Python Virtual Environment..."
|
||||||
$PYTHON_CMD -m venv venv
|
python3 -m venv venv
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "[!] ERROR: Failed to create virtual environment."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
source venv/bin/activate
|
echo "[*] Installing Backend dependencies..."
|
||||||
echo "[*] Installing Python dependencies (this may take a minute)..."
|
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then
|
||||||
pip install -q -r requirements.txt
|
# In case someone runs this in Git Bash on Windows
|
||||||
if [ $? -ne 0 ]; then
|
source venv/Scripts/activate
|
||||||
echo ""
|
else
|
||||||
echo "[!] ERROR: pip install failed. See errors above."
|
source venv/bin/activate
|
||||||
echo "[!] If you see Rust/cargo errors, your Python version may be too new."
|
|
||||||
echo "[!] Recommended: Python 3.10, 3.11, or 3.12."
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
echo "[*] Backend dependencies OK."
|
pip install -r requirements.txt
|
||||||
deactivate
|
cd ..
|
||||||
echo "[*] Installing backend Node.js dependencies..."
|
|
||||||
npm install --silent
|
|
||||||
echo "[*] Backend Node.js dependencies OK."
|
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
echo "[*] Setting up Frontend Environment..."
|
||||||
|
cd frontend
|
||||||
echo ""
|
|
||||||
echo "[*] Setting up frontend..."
|
|
||||||
cd "$SCRIPT_DIR/frontend"
|
|
||||||
if [ ! -d "node_modules" ]; then
|
if [ ! -d "node_modules" ]; then
|
||||||
echo "[*] Installing frontend dependencies..."
|
echo "[*] Installing Frontend dependencies..."
|
||||||
npm install
|
npm install
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "[!] ERROR: npm install failed. See errors above."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
echo "[*] Frontend dependencies OK."
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "======================================================="
|
echo "======================================================="
|
||||||
echo " Starting services... "
|
echo " 🚀 Starting Services... "
|
||||||
echo " Dashboard: http://localhost:3000 "
|
echo " Dashboard will be available at: http://localhost:3000"
|
||||||
echo " Keep this window open! Initial load takes ~10s. "
|
echo " Keep this window open! Note: 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
@@ -0,0 +1,21 @@
|
|||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
def create_clean_zip():
|
||||||
|
zip_name = 'ShadowBroker_v0.2.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